From 99f8719c6d29c7434f4af05da2b89b90fec58722 Mon Sep 17 00:00:00 2001 From: Adhwaith Menon <111346225+adhmenon@users.noreply.github.com> Date: Wed, 21 May 2025 19:53:44 +0530 Subject: [PATCH 1/8] feat(plugin-cc): merging next branch into task-refactor (#4284) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: robstax Co-authored-by: rstachof Co-authored-by: Coread Co-authored-by: László Vadász Co-authored-by: chrisadubois Co-authored-by: Kesava Krishnan Madavan Co-authored-by: Kacper Waśniowski Co-authored-by: Edmond Vujići <67634227+edvujic@users.noreply.github.com> Co-authored-by: Sreekanth Narayanan <131740035+sreenara@users.noreply.github.com> Co-authored-by: Peter Cole <55573154+peter7cole@users.noreply.github.com> Co-authored-by: rsarika <95286093+rsarika@users.noreply.github.com> Co-authored-by: akulakum <74420487+akulakum@users.noreply.github.com> Co-authored-by: Kesari3008 <65543166+Kesari3008@users.noreply.github.com> Co-authored-by: Bharath Balan <62698609+bhabalan@users.noreply.github.com> Co-authored-by: Marcin Co-authored-by: Bryce Tham Co-authored-by: shivani1211 Co-authored-by: mickelr <121160648+mickelr@users.noreply.github.com> --- .github/PULL_REQUEST_TEMPLATE.md | 1 - docs/index.html | 1 + docs/samples/contact-center/app.js | 167 +++- docs/samples/contact-center/index.html | 9 + .../internal-plugin-metrics/package.json | 2 +- .../call-diagnostic-metrics.ts | 71 +- .../src/call-diagnostic/config.ts | 4 + .../src/new-metrics.ts | 3 +- .../call-diagnostic-metrics.ts | 96 +- .../test/unit/spec/new-metrics.ts | 21 + .../@webex/internal-plugin-voicea/README.md | 17 + .../internal-plugin-voicea/src/constants.ts | 7 +- .../internal-plugin-voicea/src/voicea.ts | 77 +- .../src/voicea.types.ts | 7 + .../test/unit/spec/voicea.js | 124 ++- packages/@webex/media-helpers/package.json | 4 +- packages/@webex/plugin-cc/babel.config.js | 14 + packages/@webex/plugin-cc/babel.config.json | 13 - packages/@webex/plugin-cc/jest.config.js | 4 +- packages/@webex/plugin-cc/package.json | 23 +- packages/@webex/plugin-cc/src/cc.ts | 211 +++- packages/@webex/plugin-cc/src/constants.ts | 2 - packages/@webex/plugin-cc/src/index.ts | 3 + .../src/metrics/behavioral-events.ts | 14 + .../@webex/plugin-cc/src/metrics/constants.ts | 3 + .../plugin-cc/src/services/agent/types.ts | 24 +- .../plugin-cc/src/services/config/types.ts | 2 + .../src/services/task/TaskManager.ts | 23 +- .../plugin-cc/src/services/task/types.ts | 48 +- packages/@webex/plugin-cc/src/types.ts | 17 + packages/@webex/plugin-cc/src/webex-config.ts | 15 + packages/@webex/plugin-cc/src/webex.js | 28 + .../@webex/plugin-cc/test/unit/spec/cc.ts | 215 ++++- .../unit/spec/services/task/TaskManager.ts | 77 +- packages/@webex/plugin-encryption/README.md | 27 +- .../@webex/plugin-encryption/babel.config.js | 18 +- .../plugin-encryption/developer-quickstart.md | 20 +- .../@webex/plugin-encryption/jest.config.js | 4 +- .../@webex/plugin-encryption/package.json | 21 +- .../plugin-encryption/src/webex-config.ts | 15 + .../@webex/plugin-encryption/src/webex.js | 35 + packages/@webex/plugin-meetings/package.json | 5 +- .../src/common/errors/webex-errors.ts | 9 +- .../@webex/plugin-meetings/src/constants.ts | 92 +- .../src/locus-info/selfUtils.ts | 898 +++++++++--------- .../@webex/plugin-meetings/src/media/index.ts | 6 + .../plugin-meetings/src/media/properties.ts | 96 ++ .../src/meeting/in-meeting-actions.ts | 12 + .../plugin-meetings/src/meeting/index.ts | 153 ++- .../plugin-meetings/src/meeting/util.ts | 2 + .../plugin-meetings/src/meetings/index.ts | 35 + .../plugin-meetings/src/meetings/util.ts | 18 + .../plugin-meetings/src/members/index.ts | 25 + .../plugin-meetings/src/members/request.ts | 26 + .../plugin-meetings/src/members/util.ts | 16 + .../src/reachability/clusterReachability.ts | 51 +- .../plugin-meetings/src/reachability/index.ts | 56 +- .../test/unit/spec/media/index.ts | 30 + .../test/unit/spec/media/properties.ts | 130 +++ .../unit/spec/meeting/in-meeting-actions.ts | 6 + .../test/unit/spec/meeting/index.js | 191 +++- .../test/unit/spec/meeting/utils.js | 12 +- .../test/unit/spec/meetings/index.js | 43 +- .../test/unit/spec/members/index.js | 129 ++- .../test/unit/spec/members/request.js | 67 +- .../test/unit/spec/members/utils.js | 33 + .../spec/reachability/clusterReachability.ts | 96 +- .../test/unit/spec/reachability/index.ts | 89 ++ packages/calling/package.json | 2 +- packages/webex-node/package.json | 1 + packages/webex-node/src/webex-node.js | 1 + packages/webex/package.json | 7 +- packages/webex/src/contact-center.js | 23 - packages/webex/src/webex.js | 1 + webpack.config.js | 4 +- yarn.lock | 150 ++- 76 files changed, 2927 insertions(+), 1075 deletions(-) create mode 100644 packages/@webex/plugin-cc/babel.config.js delete mode 100644 packages/@webex/plugin-cc/babel.config.json create mode 100644 packages/@webex/plugin-cc/src/webex-config.ts create mode 100644 packages/@webex/plugin-cc/src/webex.js create mode 100644 packages/@webex/plugin-encryption/src/webex-config.ts create mode 100644 packages/@webex/plugin-encryption/src/webex.js delete mode 100644 packages/webex/src/contact-center.js diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 2ce122edf04..460b4e4c13b 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -57,7 +57,6 @@ This is for compliance purposes with FedRAMP program. - [ ] I have read and followed [contributing guidelines](https://github.com/webex/webex-js-sdk/blob/master/CONTRIBUTING.md#submitting-a-pull-request) - [ ] I discussed changes with code owners prior to submitting this pull request - - [ ] I have not skipped any automated checks - [ ] All existing and new tests passed - [ ] I have updated the documentation accordingly diff --git a/docs/index.html b/docs/index.html index a9a9fcb0fc7..615e8669718 100644 --- a/docs/index.html +++ b/docs/index.html @@ -97,6 +97,7 @@ Meetings API Reference Calling API Reference BYoDS API Reference + Encryption API Reference
diff --git a/docs/samples/contact-center/app.js b/docs/samples/contact-center/app.js index a51ec9765f8..2512f077155 100644 --- a/docs/samples/contact-center/app.js +++ b/docs/samples/contact-center/app.js @@ -35,6 +35,10 @@ const idleCodesDropdown = document.querySelector('#idleCodesDropdown') const setAgentStatusButton = document.querySelector('#setAgentStatus'); const logoutAgentElm = document.querySelector('#logoutAgent'); const buddyAgentsDropdownElm = document.getElementById('buddyAgentsDropdown'); +const updateAgentDeviceTypeElm = document.querySelector('#updateAgentDeviceType'); +const updateFieldsContainer = document.querySelector('#updateAgentDeviceTypeFields'); +const updateLoginOptionElm = document.querySelector('#updateLoginOption'); +const updateDialNumberElm = document.querySelector('#updateDialNumber'); const incomingCallListener = document.querySelector('#incomingsection'); const incomingDetailsElm = document.querySelector('#incoming-task'); const answerElm = document.querySelector('#answer'); @@ -610,8 +614,12 @@ function registerTaskListeners(task) { }); // Consult flows - task.on('task:consultOfferCreated', (task) => { - console.log('Consult offer created'); + task.on('task:consultCreated', (task) => { + console.info('Consult created'); + }); + + task.on('task:offerConsult', (task) => { + console.info('Received consult offer from another agent'); }); task.on('task:consultAccepted', (task) => { @@ -671,19 +679,6 @@ function registerTaskListeners(task) { } }); - task.on('task:rejected', (reason) => { - console.info('Task is rejected with reason:', reason); - if (reason === 'RONA_TIMER_EXPIRED') { - answerElm.disabled = true; - declineElm.disabled = true; - if(task.data.isConsulted) { - updateButtonsPostEndCall(); - incomingDetailsElm.innerText = ''; - currentTask = undefined; - } - } - }); - task.on('task:rejected', (reason) => { console.info('Task is rejected with reason:', reason); showAgentStatePopup(reason); @@ -720,10 +715,11 @@ function updateCallControlUI(task) { wrapupCodesDropdownElm.disabled = true; const hasParticipants = Object.keys(participants).length > 1; const isNew = task.data.interaction.state === 'new'; + const digitalChannels = ['chat', 'email', 'social']; if (isNew) { disableAllCallControls(); - } else if (task.data.interaction.mediaType === 'chat' || task.data.interaction.mediaType === 'email') { + } else if (digitalChannels.includes(task.data.interaction.mediaType)) { holdResumeElm.disabled = true; muteElm.disabled = true; pauseResumeRecordingElm.disabled = true; @@ -882,21 +878,12 @@ function register() { teamsDropdown.add(option); }); const loginVoiceOptions = agentProfile.loginVoiceOptions; - agentLogin.innerHTML = ''; // Clear previously selected option on agentLogin. + populateLoginOptions( + loginVoiceOptions.filter((o) => agentProfile.webRtcEnabled || o !== 'BROWSER') + ); dialNumber.value = agentProfile.defaultDn ? agentProfile.defaultDn : ''; dialNumber.disabled = agentProfile.defaultDn ? false : true; if (loginVoiceOptions.length > 0) loginAgentElm.disabled = false; - loginVoiceOptions.forEach((voiceOptions)=> { - if (!agentProfile.webRtcEnabled && voiceOptions === 'BROWSER') { - // Skiping the addition of browser option for webrtc disabled case - return; - } - const option = document.createElement('option'); - option.text = voiceOptions; - option.value = voiceOptions; - agentLogin.add(option); - option.selected = agentProfile.isAgentLoggedIn && voiceOptions === agentProfile.deviceType; - }); if (agentProfile.isAgentLoggedIn) { loginAgentElm.disabled = true; @@ -950,7 +937,49 @@ function register() { agentMultiLoginAlert.style.color = 'red';`` } }); - + + webex.cc.on('agent:reloginSuccess', (data) => { + console.log('Agent re-login successful', data); + loginAgentElm.disabled = true; + logoutAgentElm.classList.remove('hidden'); + updateAgentDeviceTypeElm.classList.remove('hidden'); + + agentLogin.value = data.deviceType; + agentDeviceType = data.deviceType; + + if (data.deviceType === 'BROWSER') { + dialNumber.disabled = true; + dialNumber.value = ''; + } + else { + dialNumber.disabled = false; + dialNumber.value = data.dn || ''; + } + }); + + webex.cc.on('agent:stationLoginSuccess', (data) => { + console.log('Agent station-login success', data); + loginAgentElm.disabled = true; + logoutAgentElm.classList.remove('hidden'); + updateAgentDeviceTypeElm.classList.remove('hidden'); + updateFieldsContainer.classList.add('hidden'); + + agentLogin.value = data.deviceType; + agentDeviceType = data.deviceType; + if (data.deviceType === 'BROWSER') { + dialNumber.disabled = true; + dialNumber.value = ''; + } + else { + dialNumber.disabled = false; + dialNumber.value = data.dn || ''; + } + + const auxId = data.auxCodeId?.trim() || '0'; + const idx = [...idleCodesDropdown.options].findIndex(o => o.value === auxId); + idleCodesDropdown.selectedIndex = idx >= 0 ? idx : 0; + startStateTimer(data.lastStateChangeTimestamp, data.lastIdleCodeChangeTimestamp); + }); } // New function to handle unregistration @@ -1033,12 +1062,12 @@ function doAgentLogin() { teamId: teamsDropdown.value, loginOption: agentDeviceType, dialNumber: dialNumber.value - }).then((response) => { + }) + .then((response) => { console.log('Agent Logged in successfully', response); loginAgentElm.disabled = true; logoutAgentElm.classList.remove('hidden'); - updateUnregisterButtonState(); - + updateAgentDeviceTypeElm.classList.remove('hidden'); // Read auxCode and lastStateChangeTimestamp from login response const DEFAULT_CODE = '0'; // Default code when no aux code is present const auxCodeId = response.data.auxCodeId?.trim() !== '' ? response.data.auxCodeId : DEFAULT_CODE; @@ -1072,9 +1101,12 @@ function setAgentStatus() { function logoutAgent() { - webex.cc.stationLogout({logoutReason: 'logout'}).then((response) => { - console.log('Agent logged out successfully', response); - loginAgentElm.disabled = false; + webex.cc.stationLogout({logoutReason: 'logout'}) + .then((response) => { + console.log('Agent logged out successfully', response); + loginAgentElm.disabled = false; + updateAgentDeviceTypeElm.classList.add('hidden'); + updateFieldsContainer.classList.add('hidden'); // Clear the timer when the agent logs out. if (stateTimer) { @@ -1098,6 +1130,48 @@ function logoutAgent() { }); } +async function updateAgentDeviceType() { + const payload = { + loginOption: agentDeviceType, + dialNumber: dialNumber.value + }; + try { + const response = await webex.cc.updateAgentDeviceType(payload); + console.log('Profile updated successfully', response); + } + catch (error) { + console.error('Profile update failed', error); + alert('Profile update failed'); + } +} + +function showupdateAgentDeviceTypeUI() { + updateFieldsContainer.classList.toggle('hidden'); +} + +async function applyupdateAgentDeviceType() { + const loginOption = updateLoginOptionElm.value; + const newDial = loginOption === 'BROWSER' ? '' : updateDialNumberElm.value; + const payload = { + loginOption, + dialNumber: newDial, + }; + try { + const resp = await webex.cc.updateAgentDeviceType(payload); + console.log('Profile updated', resp); + updateFieldsContainer.classList.add('hidden'); + // Reflect new values in main UI + agentLogin.value = loginOption; + agentDeviceType = loginOption; + dialNumber.value = newDial; + dialNumber.disabled = loginOption === 'BROWSER'; + } + catch (err) { + console.error('Profile update failed', err); + alert('Profile update failed'); + } +} + function showAgentStatePopup(reason) { const agentStateReasonText = document.getElementById('agentStateReasonText'); agentStateSelect.innerHTML = ''; @@ -1497,6 +1571,7 @@ function renderTaskList(taskList) { function enableAnswerDeclineButtons(task) { const callerDisplay = task.data.interaction?.callAssociatedDetails?.ani; const isNew = task.data.interaction.state === 'new' + const chatAndSocial = ['chat', 'social']; if (task.data.interaction.mediaType === 'telephony') { if (webex.cc.taskManager.webCallingService.loginOption === 'BROWSER') { answerElm.disabled = !isNew; @@ -1506,7 +1581,7 @@ function enableAnswerDeclineButtons(task) { } else { incomingDetailsElm.innerText = `Call from ${callerDisplay}...please answer on the endpoint where the agent's extension is registered`; } - } else if (task.data.interaction.mediaType === 'chat') { + } else if (chatAndSocial.includes(task.data.interaction.mediaType)) { answerElm.disabled = !isNew; declineElm.disabled = true; incomingDetailsElm.innerText = `Chat from ${callerDisplay}`; @@ -1528,8 +1603,9 @@ function handleTaskSelect(task) { enableAnswerDeclineButtons(task); engageElm.innerHTML = ``; engageElm.style.height = "100px" + const chatAndSocial = ['chat', 'social']; currentTask = task - if (task.data.interaction.mediaType === 'chat' && isBundleLoaded && !task.data.wrapUpRequired) { + if (chatAndSocial.includes(task.data.interaction.mediaType) && isBundleLoaded && !task.data.wrapUpRequired) { loadChatWidget(task); } else if (task.data.interaction.mediaType === 'email' && isBundleLoaded && !task.data.wrapUpRequired) { loadEmailWidget(task); @@ -1562,3 +1638,20 @@ function loadEmailWidget(task) { > `; } + +function populateLoginOptions(options) { + agentLogin.innerHTML = ''; + updateLoginOptionElm.innerHTML = ''; + options.forEach((opt) => { + const opt1 = document.createElement('option'); + opt1.value = opt1.text = opt; + agentLogin.add(opt1); + updateLoginOptionElm.add(opt1.cloneNode(true)); + }); +} + +updateLoginOptionElm.addEventListener('change', (e) => { + updateDialNumberElm.disabled = e.target.value === 'BROWSER'; +}); + +idleCodesDropdown.addEventListener('change', handleAgentStatus); \ No newline at end of file diff --git a/docs/samples/contact-center/index.html b/docs/samples/contact-center/index.html index c7b534ba379..8e3194a02dd 100644 --- a/docs/samples/contact-center/index.html +++ b/docs/samples/contact-center/index.html @@ -130,6 +130,15 @@

+ + +
Agent status diff --git a/packages/@webex/internal-plugin-metrics/package.json b/packages/@webex/internal-plugin-metrics/package.json index e1e23560baf..f9a0bf04802 100644 --- a/packages/@webex/internal-plugin-metrics/package.json +++ b/packages/@webex/internal-plugin-metrics/package.json @@ -37,7 +37,7 @@ "dependencies": { "@webex/common": "workspace:*", "@webex/common-timers": "workspace:*", - "@webex/event-dictionary-ts": "^1.0.1688", + "@webex/event-dictionary-ts": "^1.0.1753", "@webex/internal-plugin-metrics": "workspace:*", "@webex/test-helper-chai": "workspace:*", "@webex/test-helper-mock-webex": "workspace:*", 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 903b8896e93..a51ece7d566 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 @@ -97,6 +97,7 @@ export default class CallDiagnosticMetrics extends StatelessWebexPlugin { private hasLoggedBrowserSerial: boolean; private device: any; private delayedClientEvents: DelayedClientEvent[] = []; + private eventErrorCache: WeakMap = new WeakMap(); // the default validator before piping an event to the batcher // this function can be overridden by the user @@ -555,16 +556,31 @@ export default class CallDiagnosticMetrics extends StatelessWebexPlugin { return undefined; } + /** + * Clear the error cache + */ + clearErrorCache() { + this.eventErrorCache = new WeakMap(); + } + /** * Generate error payload for Client Event * @param rawError */ generateClientEventErrorPayload(rawError: any) { + const cachedError = this.eventErrorCache.get(rawError); + + if (cachedError) { + return [cachedError, true]; + } + const rawErrorMessage = rawError.message; const httpStatusCode = rawError.statusCode; + let payload; + if (rawError.name) { if (isBrowserMediaErrorName(rawError.name)) { - return this.getErrorPayloadForClientErrorCode({ + payload = this.getErrorPayloadForClientErrorCode({ serviceErrorCode: undefined, clientErrorCode: BROWSER_MEDIA_ERROR_NAME_TO_CLIENT_ERROR_CODES_MAP[rawError.name], serviceErrorName: rawError.name, @@ -574,11 +590,11 @@ export default class CallDiagnosticMetrics extends StatelessWebexPlugin { } } - if (isSdpOfferCreationError(rawError)) { + if (isSdpOfferCreationError(rawError) && !payload) { // error code is 30005, but that's not specific enough. we also need to check error.cause.type const causeType = rawError.cause?.type; - return this.getErrorPayloadForClientErrorCode({ + payload = this.getErrorPayloadForClientErrorCode({ serviceErrorCode: undefined, clientErrorCode: SDP_OFFER_CREATION_ERROR_MAP[causeType] || SDP_OFFER_CREATION_ERROR_MAP.GENERAL, @@ -596,8 +612,8 @@ export default class CallDiagnosticMetrics extends StatelessWebexPlugin { if (serviceErrorCode) { const clientErrorCode = SERVICE_ERROR_CODES_TO_CLIENT_ERROR_CODES_MAP[serviceErrorCode]; - if (clientErrorCode) { - return this.getErrorPayloadForClientErrorCode({ + if (clientErrorCode && !payload) { + payload = this.getErrorPayloadForClientErrorCode({ clientErrorCode, serviceErrorCode, rawErrorMessage, @@ -606,8 +622,8 @@ export default class CallDiagnosticMetrics extends StatelessWebexPlugin { } // by default, if it is locus error, return new locus err - if (isLocusServiceErrorCode(serviceErrorCode)) { - return this.getErrorPayloadForClientErrorCode({ + if (isLocusServiceErrorCode(serviceErrorCode) && !payload) { + payload = this.getErrorPayloadForClientErrorCode({ clientErrorCode: NEW_LOCUS_ERROR_CLIENT_CODE, serviceErrorCode, rawErrorMessage, @@ -616,8 +632,8 @@ export default class CallDiagnosticMetrics extends StatelessWebexPlugin { } } - if (isMeetingInfoServiceError(rawError)) { - return this.getErrorPayloadForClientErrorCode({ + if (isMeetingInfoServiceError(rawError) && !payload) { + payload = this.getErrorPayloadForClientErrorCode({ clientErrorCode: MEETING_INFO_LOOKUP_ERROR_CLIENT_CODE, serviceErrorCode, rawErrorMessage, @@ -625,8 +641,8 @@ export default class CallDiagnosticMetrics extends StatelessWebexPlugin { }); } - if (isNetworkError(rawError)) { - return this.getErrorPayloadForClientErrorCode({ + if (isNetworkError(rawError) && !payload) { + payload = this.getErrorPayloadForClientErrorCode({ clientErrorCode: NETWORK_ERROR, serviceErrorCode, payloadOverrides: rawError.payloadOverrides, @@ -635,8 +651,8 @@ export default class CallDiagnosticMetrics extends StatelessWebexPlugin { }); } - if (isUnauthorizedError(rawError)) { - return this.getErrorPayloadForClientErrorCode({ + if (isUnauthorizedError(rawError) && !payload) { + payload = this.getErrorPayloadForClientErrorCode({ clientErrorCode: AUTHENTICATION_FAILED_CODE, serviceErrorCode, payloadOverrides: rawError.payloadOverrides, @@ -645,15 +661,22 @@ export default class CallDiagnosticMetrics extends StatelessWebexPlugin { }); } - // otherwise return unkown error but passing serviceErrorCode and serviceErrorName so that we know the issue - return this.getErrorPayloadForClientErrorCode({ - clientErrorCode: UNKNOWN_ERROR, - serviceErrorCode: serviceErrorCode || UNKNOWN_ERROR, - serviceErrorName: rawError?.name, - payloadOverrides: rawError.payloadOverrides, - rawErrorMessage, - httpStatusCode, - }); + if (!payload) { + // otherwise return unkown error but passing serviceErrorCode and serviceErrorName so that we know the issue + payload = this.getErrorPayloadForClientErrorCode({ + clientErrorCode: UNKNOWN_ERROR, + serviceErrorCode: serviceErrorCode || UNKNOWN_ERROR, + serviceErrorName: rawError?.name, + payloadOverrides: rawError.payloadOverrides, + rawErrorMessage, + httpStatusCode, + }); + } + + // cache the payload for future use + this.eventErrorCache.set(rawError, payload); + + return [payload, false]; } /** @@ -834,14 +857,14 @@ export default class CallDiagnosticMetrics extends StatelessWebexPlugin { const errors: ClientEventPayloadError = []; if (rawError) { - const generatedError = this.generateClientEventErrorPayload(rawError); + const [generatedError, cached] = this.generateClientEventErrorPayload(rawError); if (generatedError) { errors.push(generatedError); } this.logger.log( CALL_DIAGNOSTIC_LOG_IDENTIFIER, 'CallDiagnosticMetrics: @prepareClientEvent. Generated errors:', - `generatedError: ${JSON.stringify(generatedError)}` + `generatedError (cached: ${cached}): ${JSON.stringify(generatedError)}` ); } diff --git a/packages/@webex/internal-plugin-metrics/src/call-diagnostic/config.ts b/packages/@webex/internal-plugin-metrics/src/call-diagnostic/config.ts index 2bb0d1192f5..f74064b1800 100644 --- a/packages/@webex/internal-plugin-metrics/src/call-diagnostic/config.ts +++ b/packages/@webex/internal-plugin-metrics/src/call-diagnostic/config.ts @@ -152,6 +152,10 @@ export const SERVICE_ERROR_CODES_TO_CLIENT_ERROR_CODES_MAP = { 403004: 4005, // Wrong password. Meeting is not allow to access since password error 403028: 4005, + // meeting is not allow to access since require panelist password + 403025: 4005, + // wrong password. Meeting is not allow to access since panelist password error + 403125: 4005, // Wrong or expired permission. Meeting is not allow to access since permissionToken error or expire 403032: 4005, // Meeting is required login for current user diff --git a/packages/@webex/internal-plugin-metrics/src/new-metrics.ts b/packages/@webex/internal-plugin-metrics/src/new-metrics.ts index 26f4714ed24..45eb156a430 100644 --- a/packages/@webex/internal-plugin-metrics/src/new-metrics.ts +++ b/packages/@webex/internal-plugin-metrics/src/new-metrics.ts @@ -74,6 +74,7 @@ class Metrics extends WebexPlugin { // @ts-ignore this.callDiagnosticMetrics = new CallDiagnosticMetrics({}, {parent: this.webex}); this.isReady = true; + this.setDelaySubmitClientEvents(this.delaySubmitClientEvents); }); } @@ -409,7 +410,7 @@ class Metrics extends WebexPlugin { public setDelaySubmitClientEvents(shouldDelay: boolean) { this.delaySubmitClientEvents = shouldDelay; - if (!shouldDelay) { + if (this.isReady && !shouldDelay) { return this.callDiagnosticMetrics.submitDelayedClientEvents(); } 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 39623e7af2d..6cd0bcdbf5a 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 @@ -1375,7 +1375,14 @@ describe('internal-plugin-metrics', () => { const getIdentifiersSpy = sinon.spy(cd, 'getIdentifiers'); const getSubServiceTypeSpy = sinon.spy(cd, 'getSubServiceType'); const validatorSpy = sinon.spy(cd, 'validator'); - sinon.stub(window.navigator, 'userAgent').get(() => userAgent); + + Object.defineProperty(global, 'navigator', { + value: { + userAgent, + }, + configurable: true, + }); + sinon.stub(bowser, 'getParser').returns(userAgent); const options = { @@ -1948,7 +1955,7 @@ describe('internal-plugin-metrics', () => { assert.deepEqual(webexLoggerLogCalls[2].args, [ 'call-diagnostic-events -> ', 'CallDiagnosticMetrics: @prepareClientEvent. Generated errors:', - `generatedError: {"fatal":true,"shownToUser":false,"name":"other","category":"expected","errorCode":4029,"serviceErrorCode":2409005,"errorDescription":"StartRecordingFailed"}`, + `generatedError (cached: false): {"fatal":true,"shownToUser":false,"name":"other","category":"expected","errorCode":4029,"serviceErrorCode":2409005,"errorDescription":"StartRecordingFailed"}`, ]); }); @@ -2028,7 +2035,7 @@ describe('internal-plugin-metrics', () => { assert.deepEqual(webexLoggerLogCalls[2].args, [ 'call-diagnostic-events -> ', 'CallDiagnosticMetrics: @prepareClientEvent. Generated errors:', - `generatedError: {"fatal":true,"shownToUser":false,"name":"other","category":"other","errorCode":9999,"errorData":{"errorName":"Error"},"serviceErrorCode":9999,"rawErrorMessage":"bad times","errorDescription":"UnknownError"}`, + `generatedError (cached: false): {"fatal":true,"shownToUser":false,"name":"other","category":"other","errorCode":9999,"errorData":{"errorName":"Error"},"serviceErrorCode":9999,"rawErrorMessage":"bad times","errorDescription":"UnknownError"}`, ]); }); @@ -2101,7 +2108,7 @@ describe('internal-plugin-metrics', () => { assert.deepEqual(webexLoggerLogCalls[2].args, [ 'call-diagnostic-events -> ', 'CallDiagnosticMetrics: @prepareClientEvent. Generated errors:', - `generatedError: {"fatal":true,"shownToUser":false,"name":"other","category":"other","errorCode":9999,"errorData":{"errorName":"Error"},"serviceErrorCode":9999,"rawErrorMessage":"bad times","errorDescription":"UnknownError"}`, + `generatedError (cached: false): {"fatal":true,"shownToUser":false,"name":"other","category":"other","errorCode":9999,"errorData":{"errorName":"Error"},"serviceErrorCode":9999,"rawErrorMessage":"bad times","errorDescription":"UnknownError"}`, ]); }); @@ -2175,7 +2182,7 @@ describe('internal-plugin-metrics', () => { assert.deepEqual(webexLoggerLogCalls[2].args, [ 'call-diagnostic-events -> ', 'CallDiagnosticMetrics: @prepareClientEvent. Generated errors:', - `generatedError: {"fatal":true,"shownToUser":false,"name":"other","category":"expected","errorCode":4029,"serviceErrorCode":2409005,"errorDescription":"StartRecordingFailed"}`, + `generatedError (cached: false): {"fatal":true,"shownToUser":false,"name":"other","category":"expected","errorCode":4029,"serviceErrorCode":2409005,"errorDescription":"StartRecordingFailed"}`, ]); }); @@ -2555,8 +2562,37 @@ describe('internal-plugin-metrics', () => { rawErrorMessage: 'bad times', }; + it('should be cached if called twice with the same payload', () => { + const error = new Error('bad times'); + const expectedPayload = { + category: 'other', + errorCode: 9999, + errorData: {errorName: 'Error'}, + serviceErrorCode: 9999, + fatal: true, + shownToUser: false, + name: 'other', + rawErrorMessage: 'bad times', + errorDescription: 'UnknownError', + } + + const [res, cached] = cd.generateClientEventErrorPayload(error); + assert.isFalse(cached); + assert.deepEqual(res, expectedPayload); + + const [res2, cached2] = cd.generateClientEventErrorPayload(error); + assert.isTrue(cached2); + assert.deepEqual(res2, expectedPayload); + + // after clearing the cache, it should be false again + cd.clearErrorCache(); + const [res3, cached3] = cd.generateClientEventErrorPayload(error); + assert.isFalse(cached3); + assert.deepEqual(res3, expectedPayload); + }); + const checkNameError = (payload: any, isExpectedToBeCalled: boolean) => { - const res = cd.generateClientEventErrorPayload(payload); + const [res, cached] = cd.generateClientEventErrorPayload(payload); const expectedResult = { category: 'expected', errorDescription: 'CameraPermissionDenied', @@ -2585,7 +2621,7 @@ describe('internal-plugin-metrics', () => { }); const checkCodeError = (payload: any, expetedRes: any) => { - const res = cd.generateClientEventErrorPayload(payload); + const [res, cached] = cd.generateClientEventErrorPayload(payload); assert.deepEqual(res, expetedRes); }; it('should generate event error payload correctly', () => { @@ -2611,7 +2647,7 @@ describe('internal-plugin-metrics', () => { }); const checkLocusError = (payload: any, isExpectedToBeCalled: boolean) => { - const res = cd.generateClientEventErrorPayload(payload); + const [res, cached] = cd.generateClientEventErrorPayload(payload); const expectedResult = { category: 'signaling', errorDescription: 'NewLocusError', @@ -2639,7 +2675,7 @@ describe('internal-plugin-metrics', () => { }); const checkMeetingInfoError = (payload: any, isExpectedToBeCalled: boolean) => { - const res = cd.generateClientEventErrorPayload(payload); + const [res, cached] = cd.generateClientEventErrorPayload(payload); const expectedResult = { category: 'signaling', errorDescription: 'MeetingInfoLookupError', @@ -2680,7 +2716,7 @@ describe('internal-plugin-metrics', () => { }); it('should return NetworkError code for a NetworkOrCORSERror', () => { - const res = cd.generateClientEventErrorPayload( + const [res, cached] = cd.generateClientEventErrorPayload( new WebexHttpError.NetworkOrCORSError({ url: 'https://example.com', statusCode: 0, @@ -2724,7 +2760,7 @@ describe('internal-plugin-metrics', () => { message: 'No codecs present in m-line with MID 0 after filtering.', }, }; - const res = cd.generateClientEventErrorPayload(error); + const [res, cached] = cd.generateClientEventErrorPayload(error); assert.deepEqual(res, { category: 'expected', errorCode: 2051, @@ -2750,7 +2786,7 @@ describe('internal-plugin-metrics', () => { message: 'empty local SDP', }, }; - const res = cd.generateClientEventErrorPayload(error); + const [res, cached] = cd.generateClientEventErrorPayload(error); assert.deepEqual(res, { category: 'media', errorCode: 2050, @@ -2775,7 +2811,7 @@ describe('internal-plugin-metrics', () => { category: 'expected', }; - const res = cd.generateClientEventErrorPayload(error); + const [res, cached] = cd.generateClientEventErrorPayload(error); assert.deepEqual(res, { category: 'expected', errorDescription: 'UnknownError', @@ -2804,7 +2840,7 @@ describe('internal-plugin-metrics', () => { category: 'expected', }; - const res = cd.generateClientEventErrorPayload(error); + const [res, cached] = cd.generateClientEventErrorPayload(error); assert.deepEqual(res, { category: 'expected', errorDescription: 'NetworkError', @@ -2819,7 +2855,7 @@ describe('internal-plugin-metrics', () => { }); it('should return AuthenticationFailed code for an Unauthorized error', () => { - const res = cd.generateClientEventErrorPayload( + const [res, cached] = cd.generateClientEventErrorPayload( new WebexHttpError.Unauthorized({ url: 'https://example.com', statusCode: 0, @@ -2852,7 +2888,7 @@ describe('internal-plugin-metrics', () => { category: 'expected', }; - const res = cd.generateClientEventErrorPayload(error); + const [res, cached] = cd.generateClientEventErrorPayload(error); assert.deepEqual(res, { category: 'expected', errorDescription: 'AuthenticationFailed', @@ -2867,7 +2903,7 @@ describe('internal-plugin-metrics', () => { }); it('should return unknown error otherwise', () => { - const res = cd.generateClientEventErrorPayload({something: 'new', message: 'bad times'}); + const [res, cached] = cd.generateClientEventErrorPayload({something: 'new', message: 'bad times'}); assert.deepEqual(res, { category: 'other', errorDescription: 'UnknownError', @@ -2881,7 +2917,7 @@ describe('internal-plugin-metrics', () => { }); it('should generate event error payload correctly for locus error 2423012', () => { - const res = cd.generateClientEventErrorPayload({ + const [res, cached] = cd.generateClientEventErrorPayload({ body: {errorCode: 2423012}, message: 'bad times', }); @@ -2897,7 +2933,7 @@ describe('internal-plugin-metrics', () => { }); }); it('should generate event error payload correctly for locus error 2409062', () => { - const res = cd.generateClientEventErrorPayload({ + const [res, cached] = cd.generateClientEventErrorPayload({ body: {errorCode: 2409062}, message: 'bad times', }); @@ -2914,7 +2950,7 @@ describe('internal-plugin-metrics', () => { }); it('should generate event error payload correctly for locus error 2423021', () => { - const res = cd.generateClientEventErrorPayload({ + const [res, cached] = cd.generateClientEventErrorPayload({ body: {errorCode: 2423021}, message: 'bad times', }); @@ -2931,7 +2967,7 @@ describe('internal-plugin-metrics', () => { }); it('should generate event error payload correctly for wdm error 4404002', () => { - const res = cd.generateClientEventErrorPayload({ + const [res, cached] = cd.generateClientEventErrorPayload({ body: {errorCode: 4404002}, message: 'Operation denied due to region restriction', }); @@ -2948,7 +2984,7 @@ describe('internal-plugin-metrics', () => { }); it('should generate event error payload correctly for wdm error 4404003', () => { - const res = cd.generateClientEventErrorPayload({ + const [res, cached] = cd.generateClientEventErrorPayload({ body: {errorCode: 4404003}, message: 'Operation denied due to region restriction', }); @@ -2966,7 +3002,7 @@ describe('internal-plugin-metrics', () => { describe('httpStatusCode', () => { it('should include httpStatusCode for browser media errors', () => { - const res = cd.generateClientEventErrorPayload({ + const [res, cached] = cd.generateClientEventErrorPayload({ name: 'PermissionDeniedError', message: 'bad times', statusCode: 401, @@ -2988,7 +3024,7 @@ describe('internal-plugin-metrics', () => { }); it('should include httpStatusCode for SdpOfferCreationErrors', () => { - const res = cd.generateClientEventErrorPayload({ + const [res, cached] = cd.generateClientEventErrorPayload({ name: 'SdpOfferCreationError', message: 'bad times', statusCode: 404, @@ -3010,7 +3046,7 @@ describe('internal-plugin-metrics', () => { }); it('should include httpStatusCode for service error codes', () => { - const res = cd.generateClientEventErrorPayload({ + const [res, cached] = cd.generateClientEventErrorPayload({ body: {errorCode: 58400}, message: 'bad times', statusCode: 400, @@ -3029,7 +3065,7 @@ describe('internal-plugin-metrics', () => { }); it('should include httpStatusCode for locus service error codes', () => { - const res = cd.generateClientEventErrorPayload({ + const [res, cached] = cd.generateClientEventErrorPayload({ body: {errorCode: 2403001}, message: 'bad times', statusCode: 400, @@ -3048,7 +3084,7 @@ describe('internal-plugin-metrics', () => { }); it('should include httpStatusCode for meetingInfo service error codes', () => { - const res = cd.generateClientEventErrorPayload({ + const [res, cached] = cd.generateClientEventErrorPayload({ body: {data: {meetingInfo: {}}}, message: 'bad times', statusCode: 400, @@ -3071,7 +3107,7 @@ describe('internal-plugin-metrics', () => { statusCode: 400, options: {service: '', headers: {}}, }); - const res = cd.generateClientEventErrorPayload(error); + const [res, cached] = cd.generateClientEventErrorPayload(error); assert.deepEqual(res, { category: 'network', errorCode: 1026, @@ -3090,7 +3126,7 @@ describe('internal-plugin-metrics', () => { statusCode: 401, options: {service: '', headers: {}}, }); - const res = cd.generateClientEventErrorPayload(error); + const [res, cached] = cd.generateClientEventErrorPayload(error); assert.deepEqual(res, { category: 'network', errorCode: 1010, @@ -3105,7 +3141,7 @@ describe('internal-plugin-metrics', () => { }); it('should include httpStatusCode for unknown errors', () => { - const res = cd.generateClientEventErrorPayload({ + const [res, cached] = cd.generateClientEventErrorPayload({ message: 'bad times', statusCode: 404, }); diff --git a/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts b/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts index 2ef93a199de..d419818f6a1 100644 --- a/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts +++ b/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts @@ -278,6 +278,27 @@ describe('internal-plugin-metrics', () => { sinon.assert.match(webex.internal.newMetrics.delaySubmitClientEvents, false); }); + + it('should not fail when called before webex is ready', () => { + + // Create mock + webex = mockWebex() + + webex.internal.newMetrics.callDiagnosticLatencies.saveTimestamp = sinon.stub(); + webex.internal.newMetrics.callDiagnosticLatencies.clearTimestamps = sinon.stub(); + webex.setTimingsAndFetch = sinon.stub(); + + sinon.assert.match(webex.internal.newMetrics.delaySubmitClientEvents, false); + + // Call the method before webex is ready, will not throw error + webex.internal.newMetrics.setDelaySubmitClientEvents(false); + webex.internal.newMetrics.setDelaySubmitClientEvents(true); + + webex.internal.newMetrics.setDelaySubmitClientEvents(false); + // Webex is ready + webex.emit('ready'); + + }); }); }); }); diff --git a/packages/@webex/internal-plugin-voicea/README.md b/packages/@webex/internal-plugin-voicea/README.md index d88d65e2fe0..d4a49b43d9b 100644 --- a/packages/@webex/internal-plugin-voicea/README.md +++ b/packages/@webex/internal-plugin-voicea/README.md @@ -54,6 +54,15 @@ await webex.internal.voicea.toggleTranscribing(true); await webex.internal.voicea.toggleTranscribing(false); ``` +Toggle Manual Captions + +- Enable/Disable Manual Captioning in a meeting + +```js +await webex.internal.voicea.toggleManualCaption(true); +await webex.internal.voicea.toggleManualCaption(false); +``` + Set Spoken Language - Host can set the spoken language of the meeting @@ -71,6 +80,14 @@ Set Caption Language webex.internal.voicea.requestLanguage('en'); ``` +Send Manual Closed Caption Messages + +- Only captioner can send manual closed captions messages + +```js +webex.internal.voicea.sendManualClosedCaption('Hello World', 9876543210, [654321, 123456, 789034, 893628], true); +``` + Other Triggers: - voicea:announcement - Triggered when voicea has diff --git a/packages/@webex/internal-plugin-voicea/src/constants.ts b/packages/@webex/internal-plugin-voicea/src/constants.ts index dd7835d88c2..b6261e3b825 100644 --- a/packages/@webex/internal-plugin-voicea/src/constants.ts +++ b/packages/@webex/internal-plugin-voicea/src/constants.ts @@ -22,7 +22,7 @@ export const AIBRIDGE_RELAY_TYPES = { }, MANUAL: { TRANSCRIPTION: 'aibridge.manual_transcription', - CAPIONER: 'client.manual_transcription', + CAPTIONER: 'client.manual_transcription', }, }; @@ -52,3 +52,8 @@ export const TURN_ON_CAPTION_STATUS = { ENABLED: 'enabled', SENDING: 'sending', }; + +export const TOGGLE_MANUAL_CAPTION_STATUS = { + IDLE: 'idle', + SENDING: 'sending', +}; diff --git a/packages/@webex/internal-plugin-voicea/src/voicea.ts b/packages/@webex/internal-plugin-voicea/src/voicea.ts index f95c425a50d..95ac143d9dd 100644 --- a/packages/@webex/internal-plugin-voicea/src/voicea.ts +++ b/packages/@webex/internal-plugin-voicea/src/voicea.ts @@ -8,6 +8,7 @@ import { VOICEA, ANNOUNCE_STATUS, TURN_ON_CAPTION_STATUS, + TOGGLE_MANUAL_CAPTION_STATUS, } from './constants'; // eslint-disable-next-line no-unused-vars import { @@ -38,6 +39,8 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { private captionStatus: string; + private toggleManualCaptionStatus: string; + /** * @param {Object} e * @returns {undefined} @@ -58,7 +61,7 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { this.processTranscription(e.data.voiceaPayload); break; case AIBRIDGE_RELAY_TYPES.MANUAL.TRANSCRIPTION: - case AIBRIDGE_RELAY_TYPES.MANUAL.CAPIONER: + case AIBRIDGE_RELAY_TYPES.MANUAL.CAPTIONER: this.processManualTranscription({ ...e.data.transcriptPayload, sender: e.headers?.from, @@ -94,6 +97,7 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { this.hasSubscribedToEvents = false; this.announceStatus = ANNOUNCE_STATUS.IDLE; this.captionStatus = TURN_ON_CAPTION_STATUS.IDLE; + this.toggleManualCaptionStatus = TOGGLE_MANUAL_CAPTION_STATUS.IDLE; } /** @@ -107,6 +111,7 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { this.vmcDeviceId = undefined; this.announceStatus = ANNOUNCE_STATUS.IDLE; this.captionStatus = TURN_ON_CAPTION_STATUS.IDLE; + this.toggleManualCaptionStatus = TOGGLE_MANUAL_CAPTION_STATUS.IDLE; } /** @@ -307,6 +312,55 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { this.seqNum += 1; }; + /** + * Send manual closed captions to voicea service + * @param {string} text + * @param {number} timeStamp + * @param {number[]} csis + * @param {boolean} isFinal + * @returns {void} + */ + public sendManualClosedCaption = ( + text: string, + timeStamp: number, + csis: number[], + isFinal: boolean + ): void => { + // @ts-ignore + if (!this.webex.internal.llm.isConnected()) return; + + // @ts-ignore + this.webex.internal.llm.socket.send({ + id: `${this.seqNum}`, + type: 'publishRequest', + recipients: { + // @ts-ignore + route: this.webex.internal.llm.getBinding(), + }, + headers: {}, + data: { + eventType: 'relay.event', + relayType: AIBRIDGE_RELAY_TYPES.MANUAL.CAPTIONER, + transcriptPayload: { + type: isFinal + ? TRANSCRIPTION_TYPE.MANUAL_CAPTION_FINAL_RESULT + : TRANSCRIPTION_TYPE.MANUAL_CAPTION_INTERIM_RESULT, + id: uuid.v4(), + transcripts: [ + { + text, + start_millis: timeStamp, + end_millis: timeStamp, + csis, + }, + ], + }, + }, + trackingId: `${config.trackingIdPrefix}_${uuid.v4().toString()}`, + }); + this.seqNum += 1; + }; + /** * request turn on Captions * @param {string} [languageCode] - Optional Parameter for spoken language code. Defaults to English @@ -387,7 +441,7 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { /** * Toggle transcribing for highlights - * @param {bool} activate if true transcribing is turned on + * @param {boolean} activate true means to turn on transcribing and false means to turn off * @param {string} spokenLanguage language code for spoken language * @returns {Promise} */ @@ -417,10 +471,14 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { /** * Toggle turn on manual caption - * @param {bool} enable if true manual caption is turned on + * @param {boolean} enable true means to turn on manual caption, false means to turn off * @returns {Promise} */ public toggleManualCaption = (enable: boolean): undefined | Promise => { + if (this.toggleManualCaptionStatus === TOGGLE_MANUAL_CAPTION_STATUS.SENDING) return undefined; + + this.toggleManualCaptionStatus = TOGGLE_MANUAL_CAPTION_STATUS.SENDING; + // @ts-ignore return this.request({ method: 'PUT', @@ -431,9 +489,16 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { enable, }, }, - }).then((): undefined | Promise => { - return undefined; - }); + }) + .then((): undefined | Promise => { + this.toggleManualCaptionStatus = TOGGLE_MANUAL_CAPTION_STATUS.IDLE; + + return undefined; + }) + .catch(() => { + this.toggleManualCaptionStatus = TOGGLE_MANUAL_CAPTION_STATUS.IDLE; + throw new Error('toggle manual captions fail'); + }); }; /** diff --git a/packages/@webex/internal-plugin-voicea/src/voicea.types.ts b/packages/@webex/internal-plugin-voicea/src/voicea.types.ts index 20bc3c46754..cc804a57a25 100644 --- a/packages/@webex/internal-plugin-voicea/src/voicea.types.ts +++ b/packages/@webex/internal-plugin-voicea/src/voicea.types.ts @@ -84,6 +84,13 @@ interface IVoiceaChannel { turnOnCaptions: () => undefined | Promise; toggleTranscribing: (activate: boolean, spokenLanguage: string) => undefined | Promise; deregisterEvents: () => void; + toggleManualCaption: (enable: boolean) => undefined | Promise; + sendManualClosedCaption: ( + text: string, + timeStamp: number, + csis: number[], + isFinal: boolean + ) => void; } type MeetingTranscripts = { diff --git a/packages/@webex/internal-plugin-voicea/test/unit/spec/voicea.js b/packages/@webex/internal-plugin-voicea/test/unit/spec/voicea.js index 75bbfcedf2a..dbeaaaa2578 100644 --- a/packages/@webex/internal-plugin-voicea/test/unit/spec/voicea.js +++ b/packages/@webex/internal-plugin-voicea/test/unit/spec/voicea.js @@ -7,7 +7,7 @@ import Mercury from '@webex/internal-plugin-mercury'; import LLMChannel from '@webex/internal-plugin-llm'; import VoiceaService from '../../../src/index'; -import {EVENT_TRIGGERS} from '../../../src/constants'; +import {EVENT_TRIGGERS, TOGGLE_MANUAL_CAPTION_STATUS} from '../../../src/constants'; describe('plugin-voicea', () => { const locusUrl = 'locusUrl'; @@ -91,6 +91,103 @@ describe('plugin-voicea', () => { }); }); + describe('#sendManualClosedCaption', () => { + beforeEach(async () => { + const mockWebSocket = new MockWebSocket(); + voiceaService.webex.internal.llm.socket = mockWebSocket; + voiceaService.seqNum = 1; + }); + + it('sends interim manual closed caption when connected', () => { + const text = 'Test interim caption'; + const timeStamp = 1234567890; + const csis = [123456]; + const isFinal = false; + + voiceaService.sendManualClosedCaption(text, timeStamp, csis, isFinal); + + assert.calledOnceWithExactly( + voiceaService.webex.internal.llm.socket.send, + { + id: '1', + type: 'publishRequest', + recipients: {route: undefined}, + headers: {}, + data: { + eventType: 'relay.event', + relayType: 'client.manual_transcription', + transcriptPayload: { + type: 'manual_caption_interim_result', + id: sinon.match.string, + transcripts: [ + { + text: 'Test interim caption', + start_millis: 1234567890, + end_millis: 1234567890, + csis: [123456], + }, + ], + }, + }, + trackingId: sinon.match.string, + } + ); + // seqNum should increment + assert.equal(voiceaService.seqNum, 2); + }); + + it('sends final manual closed caption when connected', () => { + const text = 'Test final caption'; + const timeStamp = 9876543210; + const csis = [654321]; + const isFinal = true; + + voiceaService.sendManualClosedCaption(text, timeStamp, csis, isFinal); + + assert.calledOnceWithExactly( + voiceaService.webex.internal.llm.socket.send, + { + id: '1', + type: 'publishRequest', + recipients: {route: undefined}, + headers: {}, + data: { + eventType: 'relay.event', + relayType: 'client.manual_transcription', + transcriptPayload: { + type: 'manual_caption_final_result', + id: sinon.match.string, + transcripts: [ + { + text: 'Test final caption', + start_millis: 9876543210, + end_millis: 9876543210, + csis: [654321], + }, + ], + }, + }, + trackingId: sinon.match.string, + } + ); + // seqNum should increment + assert.equal(voiceaService.seqNum, 2); + }); + + it('does not send if not connected', () => { + voiceaService.webex.internal.llm.isConnected.returns(false); + + const text = 'Should not send'; + const timeStamp = 111; + const csis = [1]; + const isFinal = true; + + voiceaService.sendManualClosedCaption(text, timeStamp, csis, isFinal); + + assert.notCalled(voiceaService.webex.internal.llm.socket.send); + }); + }); + describe('#deregisterEvents', () => { beforeEach(async () => { const mockWebSocket = new MockWebSocket(); @@ -441,20 +538,10 @@ describe('plugin-voicea', () => { const mockWebSocket = new MockWebSocket(); voiceaService.webex.internal.llm.socket = mockWebSocket; + voiceaService.toggleManualCaptionStatus = TOGGLE_MANUAL_CAPTION_STATUS.IDLE; }); it('turns on manual caption', async () => { - // Turn on captions - await voiceaService.turnOnCaptions(); - - // eslint-disable-next-line no-underscore-dangle - voiceaService.webex.internal.llm._emit('event:relay.event', { - headers: {from: 'ws'}, - data: {relayType: 'voicea.annc', voiceaPayload: {}}, - }); - - voiceaService.listenToEvents(); - await voiceaService.toggleManualCaption(true); sinon.assert.calledWith( voiceaService.request, @@ -469,10 +556,6 @@ describe('plugin-voicea', () => { it('turns off manual caption', async () => { - await voiceaService.toggleManualCaption(true); - - voiceaService.listenToEvents(); - await voiceaService.toggleManualCaption(false); sinon.assert.calledWith( voiceaService.request, @@ -484,6 +567,14 @@ describe('plugin-voicea', () => { ); }); + + it('ignore toggle manual caption', async () => { + voiceaService.toggleManualCaptionStatus = TOGGLE_MANUAL_CAPTION_STATUS.SENDING; + await voiceaService.toggleManualCaption(true); + + sinon.assert.notCalled(voiceaService.request); + + }); }); describe('#processCaptionLanguageResponse', () => { @@ -931,3 +1022,4 @@ describe('plugin-voicea', () => { }); }); }); + diff --git a/packages/@webex/media-helpers/package.json b/packages/@webex/media-helpers/package.json index 2fd6703ed73..38b899a6476 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.14.7", + "@webex/internal-media-core": "2.16.0", "@webex/ts-events": "^1.1.0", - "@webex/web-media-effects": "2.19.0" + "@webex/web-media-effects": "2.27.1" }, "browserify": { "transform": [ diff --git a/packages/@webex/plugin-cc/babel.config.js b/packages/@webex/plugin-cc/babel.config.js new file mode 100644 index 00000000000..7d7c32e4731 --- /dev/null +++ b/packages/@webex/plugin-cc/babel.config.js @@ -0,0 +1,14 @@ +module.exports = { + plugins: ['@webex/babel-config-legacy/inject-package-version'], + presets: [ + [ + '@babel/preset-env', + { + targets: { + node: 'current', + }, + }, + ], + '@babel/preset-typescript', + ], +}; diff --git a/packages/@webex/plugin-cc/babel.config.json b/packages/@webex/plugin-cc/babel.config.json deleted file mode 100644 index a755b2392fc..00000000000 --- a/packages/@webex/plugin-cc/babel.config.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "presets": [ - [ - "@babel/preset-env", - { - "targets": { - "node": "current" - } - } - ], - "@babel/preset-typescript" - ], -} \ No newline at end of file diff --git a/packages/@webex/plugin-cc/jest.config.js b/packages/@webex/plugin-cc/jest.config.js index ccc2d6c969b..048a5369025 100644 --- a/packages/@webex/plugin-cc/jest.config.js +++ b/packages/@webex/plugin-cc/jest.config.js @@ -2,9 +2,11 @@ import config from '@webex/jest-config-legacy'; const jestConfig = { rootDir: './', - transformIgnorePatterns: [], testPathIgnorePatterns: ['/node_modules/', '/dist/'], testResultsProcessor: 'jest-junit', + transformIgnorePatterns: [ + '/node_modules/(?!(uuid)/)', // Transform `uuid` using Babel + ], // Clear mocks in between tests by default clearMocks: true, collectCoverage: true, diff --git a/packages/@webex/plugin-cc/package.json b/packages/@webex/plugin-cc/package.json index 957a751310d..4f8dd735b5f 100644 --- a/packages/@webex/plugin-cc/package.json +++ b/packages/@webex/plugin-cc/package.json @@ -1,8 +1,22 @@ { "name": "@webex/plugin-cc", "description": "This package provides a set of APIs to perform various operations for the Agent flow within Webex Contact Center", - "main": "dist/index.js", + "contributors": [ + "Adhwaith Menon ", + "Bharath Balan ", + "Kesava Krishnan Madavan ", + "Priya Kesari ", + "Rajesh Kumar ", + "Ravi Chandra Sekhar Sarika ", + "Shreyas Sharma ", + "Sreekanth Narayanan " + ], + "main": "dist/webex.js", "devMain": "src/index.ts", + "exports": { + ".": "./dist/webex.js", + "./package": "./package.json" + }, "repository": { "type": "git", "url": "https://github.com/webex/webex-js-sdk.git", @@ -27,9 +41,12 @@ "@webex/calling": "workspace:*", "@webex/internal-plugin-mercury": "workspace:*", "@webex/internal-plugin-metrics": "workspace:*", + "@webex/internal-plugin-support": "workspace:*", + "@webex/plugin-authorization": "workspace:*", + "@webex/plugin-logger": "workspace:*", "@webex/webex-core": "workspace:*", - "buffer": "6.0.3", - "jest-html-reporters": "3.0.11" + "jest-html-reporters": "3.0.11", + "lodash": "^4.17.21" }, "devDependencies": { "@babel/preset-typescript": "7.22.11", diff --git a/packages/@webex/plugin-cc/src/cc.ts b/packages/@webex/plugin-cc/src/cc.ts index d9899b1c522..62b824957fd 100644 --- a/packages/@webex/plugin-cc/src/cc.ts +++ b/packages/@webex/plugin-cc/src/cc.ts @@ -1,5 +1,6 @@ import {WebexPlugin} from '@webex/webex-core'; import EventEmitter from 'events'; +import {v4 as uuidv4} from 'uuid'; import { SetStateResponse, CCPluginConfig, @@ -7,6 +8,7 @@ import { WebexSDK, LoginOption, AgentLogin, + AgentDeviceUpdate, StationLoginResponse, StationLogoutResponse, StationReLoginResponse, @@ -14,13 +16,13 @@ import { BuddyAgents, SubscribeRequest, UploadLogsResponse, + UpdateDeviceTypeResponse, + GenericError, } from './types'; import { READY, CC_FILE, EMPTY_STRING, - AGENT_STATE_CHANGE, - AGENT_MULTI_LOGIN, OUTDIAL_DIRECTION, ATTRIBUTES, OUTDIAL_MEDIA_TYPE, @@ -32,15 +34,9 @@ import {AGENT, WEB_RTC_PREFIX} from './services/constants'; import Services from './services'; import WebexRequest from './services/core/WebexRequest'; import LoggerProxy from './logger-proxy'; -import {StateChange, Logout, StateChangeSuccess} from './services/agent/types'; +import {StateChange, Logout, StateChangeSuccess, AGENT_EVENTS} from './services/agent/types'; import {getErrorDetails} from './services/core/Utils'; -import { - Profile, - WelcomeEvent, - CC_EVENTS, - CC_AGENT_EVENTS, - ContactServiceQueue, -} from './services/config/types'; +import {Profile, WelcomeEvent, CC_EVENTS, ContactServiceQueue} from './services/config/types'; import { AGENT_STATE_AVAILABLE, AGENT_STATE_AVAILABLE_ID, @@ -375,6 +371,7 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter const resp = await loginResponse; const {channelsMap, ...loginData} = resp.data; + this.agentConfig.currentTeamId = resp.data.teamId; const response = { ...loginData, mmProfile: { @@ -559,20 +556,93 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter private handleWebSocketMessage = (event: string) => { const eventData = JSON.parse(event); - // Re-emit the events related to agent - if (Object.values(CC_AGENT_EVENTS).includes(eventData.data?.type)) { + // Re-emit all the events related to agent except keep-alives + if (!eventData.keepalive && eventData.data && eventData.data.type) { // @ts-ignore this.emit(eventData.data.type, eventData.data); } - if (eventData.type === CC_EVENTS.AGENT_STATE_CHANGE) { - // @ts-ignore - this.emit(AGENT_STATE_CHANGE, eventData.data); + if (!eventData.type) { + return; } - if (eventData.type === CC_EVENTS.AGENT_MULTI_LOGIN) { - // @ts-ignore - this.emit(AGENT_MULTI_LOGIN, eventData.data); + switch (eventData.type) { + case CC_EVENTS.AGENT_MULTI_LOGIN: + // @ts-ignore + this.emit(AGENT_EVENTS.AGENT_MULTI_LOGIN, eventData.data); + break; + case CC_EVENTS.AGENT_STATE_CHANGE: + // @ts-ignore + this.emit(AGENT_EVENTS.AGENT_STATE_CHANGE, eventData.data); + break; + default: + break; + } + + if (!(eventData.data && eventData.data.type)) { + return; + } + + switch (eventData.data.type) { + case CC_EVENTS.AGENT_STATION_LOGIN_SUCCESS: { + const {channelsMap, ...loginData} = eventData.data; + const stationLoginData = { + ...loginData, + mmProfile: { + chat: channelsMap.chat?.length, + email: channelsMap.email?.length, + social: channelsMap.social?.length, + telephony: channelsMap.telephony?.length, + }, + notifsTrackingId: eventData.trackingId, + }; + // @ts-ignore + this.emit(AGENT_EVENTS.AGENT_STATION_LOGIN_SUCCESS, stationLoginData); + break; + } + case CC_EVENTS.AGENT_RELOGIN_SUCCESS: + { + const {channelsMap, ...loginData} = eventData.data; + const stationReLoginData = { + ...loginData, + mmProfile: { + chat: channelsMap.chat?.length, + email: channelsMap.email?.length, + social: channelsMap.social?.length, + telephony: channelsMap.telephony?.length, + }, + notifsTrackingId: eventData.trackingId, + }; + // @ts-ignore + this.emit(AGENT_EVENTS.AGENT_RELOGIN_SUCCESS, stationReLoginData); + } + break; + case CC_EVENTS.AGENT_STATE_CHANGE_SUCCESS: + // @ts-ignore + this.emit(AGENT_EVENTS.AGENT_STATE_CHANGE_SUCCESS, eventData.data); + break; + case CC_EVENTS.AGENT_STATE_CHANGE_FAILED: + // @ts-ignore + this.emit(AGENT_EVENTS.AGENT_STATE_CHANGE_FAILED, eventData.data); + break; + case CC_EVENTS.AGENT_STATION_LOGIN_FAILED: + // @ts-ignore + this.emit(AGENT_EVENTS.AGENT_STATION_LOGIN_FAILED, eventData.data); + break; + case CC_EVENTS.AGENT_LOGOUT_SUCCESS: + // @ts-ignore + this.emit(AGENT_EVENTS.AGENT_LOGOUT_SUCCESS, eventData.data); + break; + case CC_EVENTS.AGENT_LOGOUT_FAILED: + // @ts-ignore + this.emit(AGENT_EVENTS.AGENT_LOGOUT_FAILED, eventData.data); + break; + case CC_EVENTS.AGENT_DN_REGISTERED: + // @ts-ignore + this.emit(AGENT_EVENTS.AGENT_DN_REGISTERED, eventData.data); + break; + default: + break; } }; @@ -634,6 +704,7 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter let {auxCodeId} = reLoginResponse.data; this.agentConfig.lastStateChangeTimestamp = lastStateChangeTimestamp; this.agentConfig.lastIdleCodeChangeTimestamp = lastIdleCodeChangeTimestamp; + this.agentConfig.currentTeamId = reLoginResponse.data.teamId; await this.handleDeviceType(deviceType as LoginOption, dn); if (lastStateChangeReason === 'agent-wss-disconnect') { @@ -808,10 +879,112 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter * messages, and client-side events, then securely submits them to Webex's diagnostics * service. The returned tracking ID, feedbackID can be provided to Webex support for faster * issue resolution. - * @returns Promise + * @returns Promise * @throws Error */ public async uploadLogs(): Promise { return this.webexRequest.uploadLogs(); } + + /** + * Updates the agent device type. + * This method allows the agent to change their device type (e.g., from BROWSER to EXTENSION or anything else). + * It will also throw an error if the new device type is the same as the current one. + * @param data type is AgentDeviceUpdate - The data required to update the agent device type, including the new login option and dial number. + * @returns Promise + * @throws Error + * @example + * ```typescript + * const data = { + * loginOption: 'EXTENSION', + * dialNumber: '1234567890', + * }; + * const result = await webex.cc.updateAgentDeviceType(data); + * ``` + */ + public async updateAgentDeviceType(data: AgentDeviceUpdate): Promise { + this.metricsManager.timeEvent([ + METRIC_EVENT_NAMES.AGENT_DEVICE_TYPE_UPDATE_SUCCESS, + METRIC_EVENT_NAMES.AGENT_DEVICE_TYPE_UPDATE_FAILED, + ]); + + const trackingId = `WX_CC_SDK_${uuidv4()}`; + + LoggerProxy.info(`[${trackingId}] updateAgentDeviceType | starting profile update`, { + module: CC_FILE, + method: this.updateAgentDeviceType.name, + }); + + try { + // ensure we change device type + if (this.webCallingService?.loginOption === data.loginOption) { + const message = + 'Will not proceed with device update as new Device type is same as current device type'; + const err = new Error(message) as GenericError; + err.details = { + type: 'Identical Device Change Failure', + orgId: this.$webex.credentials.getOrgId(), + trackingId, + data: { + agentId: this.agentConfig.agentId, + reasonCode: 'R002', + reason: message, + }, + }; + throw err; + } + + await this.stationLogout({ + logoutReason: 'User requested agent device change', + }); + + const loginPayload: AgentLogin = { + teamId: this.agentConfig.currentTeamId ?? EMPTY_STRING, + loginOption: data.loginOption, + dialNumber: data.dialNumber, + }; + + const resp = await this.stationLogin(loginPayload); + + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.AGENT_DEVICE_TYPE_UPDATE_SUCCESS, + { + ...MetricsManager.getCommonTrackingFieldForAQMResponse(resp), + loginType: data.loginOption, + }, + ['behavioral', 'business', 'operational'] + ); + + LoggerProxy.log(`[${trackingId}] updateAgentDeviceType | profile updated successfully`, { + module: CC_FILE, + method: this.updateAgentDeviceType.name, + }); + + const deviceTypeUpdateResponse: UpdateDeviceTypeResponse = { + ...resp, + type: 'AgentDeviceTypeUpdateSuccess', + }; + + return deviceTypeUpdateResponse; + } catch (error) { + const failure = (error as GenericError).details as Failure; + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.AGENT_DEVICE_TYPE_UPDATE_FAILED, + { + ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed(failure), + loginType: data.loginOption, + }, + ['behavioral', 'business', 'operational'] + ); + + LoggerProxy.error( + `[${trackingId}] updateAgentDeviceType | error updating profile: ${error}`, + { + module: CC_FILE, + method: this.updateAgentDeviceType.name, + } + ); + throw error; + } + } } diff --git a/packages/@webex/plugin-cc/src/constants.ts b/packages/@webex/plugin-cc/src/constants.ts index 8d94936d02c..7ea8c04c076 100644 --- a/packages/@webex/plugin-cc/src/constants.ts +++ b/packages/@webex/plugin-cc/src/constants.ts @@ -13,8 +13,6 @@ export const WEB_SOCKET_MANAGER_FILE = 'WebSocketManager'; export const AQM_REQS_FILE = 'aqm-reqs'; export const WEBEX_REQUEST_FILE = 'WebexRequest'; export const TASK_MANAGER_FILE = 'TaskManager'; -export const AGENT_STATE_CHANGE = 'agent:stateChange'; -export const AGENT_MULTI_LOGIN = 'agent:multiLogin'; // AGENT OUTDIAL CONSTANTS export const OUTDIAL_DIRECTION = 'OUTBOUND'; export const ATTRIBUTES = {}; diff --git a/packages/@webex/plugin-cc/src/index.ts b/packages/@webex/plugin-cc/src/index.ts index b79adf37f37..b133b896d54 100644 --- a/packages/@webex/plugin-cc/src/index.ts +++ b/packages/@webex/plugin-cc/src/index.ts @@ -3,6 +3,9 @@ import {registerPlugin} from '@webex/webex-core'; import config from './config'; import ContactCenter from './cc'; +export {TASK_EVENTS} from './services/task/types'; +export {AGENT_EVENTS} from './services/agent/types'; + registerPlugin('cc', ContactCenter, { config, }); diff --git a/packages/@webex/plugin-cc/src/metrics/behavioral-events.ts b/packages/@webex/plugin-cc/src/metrics/behavioral-events.ts index 63050c03bc2..e2219c745b1 100644 --- a/packages/@webex/plugin-cc/src/metrics/behavioral-events.ts +++ b/packages/@webex/plugin-cc/src/metrics/behavioral-events.ts @@ -271,6 +271,20 @@ const eventTaxonomyMap: Record = { target: 'upload_logs', verb: 'fail', }, + + // update profile + [METRIC_EVENT_NAMES.AGENT_DEVICE_TYPE_UPDATE_SUCCESS]: { + product, + agent: 'user', + target: 'agent_device_type_update', + verb: 'complete', + }, + [METRIC_EVENT_NAMES.AGENT_DEVICE_TYPE_UPDATE_FAILED]: { + product, + agent: 'user', + target: 'agent_device_type_update', + verb: 'fail', + }, }; export function getEventTaxonomy(name: METRIC_EVENT_NAMES): BehavioralEventTaxonomy | undefined { diff --git a/packages/@webex/plugin-cc/src/metrics/constants.ts b/packages/@webex/plugin-cc/src/metrics/constants.ts index 441f35b9c41..4b060e20acc 100644 --- a/packages/@webex/plugin-cc/src/metrics/constants.ts +++ b/packages/@webex/plugin-cc/src/metrics/constants.ts @@ -50,6 +50,9 @@ export const METRIC_EVENT_NAMES = { UPLOAD_LOGS_FAILED: 'Upload Logs Failed', WEBSOCKET_DEREGISTER_SUCCESS: 'Websocket Deregister Success', WEBSOCKET_DEREGISTER_FAIL: 'Websocket Deregister Failed', + + AGENT_DEVICE_TYPE_UPDATE_SUCCESS: 'Agent Device Type Update Success', + AGENT_DEVICE_TYPE_UPDATE_FAILED: 'Agent Device Type Update Failed', } as const; // Derive the type using the utility type diff --git a/packages/@webex/plugin-cc/src/services/agent/types.ts b/packages/@webex/plugin-cc/src/services/agent/types.ts index fddc2454315..8ac1730e0eb 100644 --- a/packages/@webex/plugin-cc/src/services/agent/types.ts +++ b/packages/@webex/plugin-cc/src/services/agent/types.ts @@ -108,7 +108,16 @@ export type StationLoginSuccessResponse = { notifsTrackingId: string; }; -export type Logout = {logoutReason?: 'User requested logout' | 'Inactivity Logout'}; +export type DeviceTypeUpdateSuccess = Omit & { + type: 'AgentDeviceTypeUpdateSuccess'; +}; + +export type Logout = { + logoutReason?: + | 'User requested logout' + | 'Inactivity Logout' + | 'User requested agent device change'; +}; export type AgentState = 'Available' | 'Idle' | 'RONA' | string; @@ -164,3 +173,16 @@ export type BuddyAgentsSuccess = Msg<{ type: 'BuddyAgents'; agentList: Array; }>; + +export enum AGENT_EVENTS { + AGENT_STATE_CHANGE = 'agent:stateChange', + AGENT_MULTI_LOGIN = 'agent:multiLogin', + AGENT_STATION_LOGIN_SUCCESS = 'agent:stationLoginSuccess', + AGENT_STATION_LOGIN_FAILED = 'agent:stationLoginFailed', + AGENT_LOGOUT_SUCCESS = 'agent:logoutSuccess', + AGENT_LOGOUT_FAILED = 'agent:logoutFailed', + AGENT_DN_REGISTERED = 'agent:dnRegistered', + AGENT_RELOGIN_SUCCESS = 'agent:reloginSuccess', + AGENT_STATE_CHANGE_SUCCESS = 'agent:stateChangeSuccess', + AGENT_STATE_CHANGE_FAILED = 'agent:stateChangeFailed', +} diff --git a/packages/@webex/plugin-cc/src/services/config/types.ts b/packages/@webex/plugin-cc/src/services/config/types.ts index 1d19fa31498..bec7f39b44a 100644 --- a/packages/@webex/plugin-cc/src/services/config/types.ts +++ b/packages/@webex/plugin-cc/src/services/config/types.ts @@ -42,6 +42,7 @@ export const CC_TASK_EVENTS = { AGENT_OFFER_CONTACT: 'AgentOfferContact', AGENT_CONTACT_ASSIGNED: 'AgentContactAssigned', AGENT_CONTACT_UNASSIGNED: 'AgentContactUnassigned', + AGENT_INVITE_FAILED: 'AgentInviteFailed', } as const; // Define the CC_AGENT_EVENTS object @@ -614,6 +615,7 @@ export type Profile = { tenantTimezone?: string; loginVoiceOptions?: LoginOption[]; deviceType?: LoginOption; + currentTeamId?: string; webRtcEnabled: boolean; organizationIdleCodes?: Entity[]; isRecordingManagementEnabled?: boolean; diff --git a/packages/@webex/plugin-cc/src/services/task/TaskManager.ts b/packages/@webex/plugin-cc/src/services/task/TaskManager.ts index ad70dacee73..9769e8969f8 100644 --- a/packages/@webex/plugin-cc/src/services/task/TaskManager.ts +++ b/packages/@webex/plugin-cc/src/services/task/TaskManager.ts @@ -106,6 +106,7 @@ export default class TaskManager extends EventEmitter { module: TASK_MANAGER_FILE, method: 'registerTaskListeners', }); + this.emit(TASK_EVENTS.TASK_OFFER_CONTACT, task); break; case CC_EVENTS.AGENT_OUTBOUND_FAILED: // We don't have to emit any event here since this will be result of promise. @@ -143,6 +144,7 @@ export default class TaskManager extends EventEmitter { task.emit(TASK_EVENTS.TASK_REJECT, payload.data.reason); break; case CC_EVENTS.CONTACT_ENDED: + case CC_EVENTS.AGENT_INVITE_FAILED: task = this.updateTaskData(task, { ...payload.data, wrapUpRequired: payload.data.interaction.state !== 'new', @@ -178,7 +180,7 @@ export default class TaskManager extends EventEmitter { ...payload.data, isConsulted: false, // This ensures that the task consult status is always reset }); - // Do not emit anything since this be received only as a result of an API invocation(handled by a promise) + task.emit(TASK_EVENTS.TASK_CONSULT_CREATED, task); break; case CC_EVENTS.AGENT_OFFER_CONSULT: // Received when other agent sends us a consult offer @@ -186,7 +188,7 @@ export default class TaskManager extends EventEmitter { ...payload.data, isConsulted: true, // This ensures that the task is marked as us being requested for a consult }); - + task.emit(TASK_EVENTS.TASK_OFFER_CONSULT, task); break; case CC_EVENTS.AGENT_CONSULTING: // Received when agent is in an active consult state @@ -223,6 +225,23 @@ export default class TaskManager extends EventEmitter { break; case CC_EVENTS.AGENT_WRAPPEDUP: this.removeTaskFromCollection(task); + task.emit(TASK_EVENTS.TASK_WRAPPEDUP, task); + break; + case CC_EVENTS.CONTACT_RECORDING_PAUSED: + task = this.updateTaskData(task, payload.data); + task.emit(TASK_EVENTS.TASK_RECORDING_PAUSED, task); + break; + case CC_EVENTS.CONTACT_RECORDING_PAUSE_FAILED: + task = this.updateTaskData(task, payload.data); + task.emit(TASK_EVENTS.TASK_RECORDING_PAUSE_FAILED, task); + break; + case CC_EVENTS.CONTACT_RECORDING_RESUMED: + task = this.updateTaskData(task, payload.data); + task.emit(TASK_EVENTS.TASK_RECORDING_RESUMED, task); + break; + case CC_EVENTS.CONTACT_RECORDING_RESUME_FAILED: + task = this.updateTaskData(task, payload.data); + task.emit(TASK_EVENTS.TASK_RECORDING_RESUME_FAILED, task); break; default: break; diff --git a/packages/@webex/plugin-cc/src/services/task/types.ts b/packages/@webex/plugin-cc/src/services/task/types.ts index 5cf11a35d1e..0bbf5322ea9 100644 --- a/packages/@webex/plugin-cc/src/services/task/types.ts +++ b/packages/@webex/plugin-cc/src/services/task/types.ts @@ -36,27 +36,33 @@ export const MEDIA_CHANNEL = { export type MEDIA_CHANNEL = Enum; -export const TASK_EVENTS = { - TASK_INCOMING: 'task:incoming', - TASK_ASSIGNED: 'task:assigned', - TASK_MEDIA: 'task:media', - TASK_UNASSIGNED: 'task:unassigned', - TASK_HOLD: 'task:hold', - TASK_UNHOLD: 'task:unhold', - TASK_CONSULT_END: 'task:consultEnd', - TASK_CONSULT_QUEUE_CANCELLED: 'task:consultQueueCancelled', - TASK_CONSULT_QUEUE_FAILED: 'task:consultQueueFailed', - TASK_CONSULT_ACCEPTED: 'task:consultAccepted', - TASK_CONSULTING: 'task:consulting', - TASK_PAUSE: 'task:pause', - TASK_RESUME: 'task:resume', - TASK_END: 'task:end', - TASK_WRAPUP: 'task:wrapup', - TASK_REJECT: 'task:rejected', - TASK_HYDRATE: 'task:hydrate', -} as const; - -export type TASK_EVENTS = Enum; +export enum TASK_EVENTS { + TASK_INCOMING = 'task:incoming', + TASK_ASSIGNED = 'task:assigned', + TASK_MEDIA = 'task:media', + TASK_UNASSIGNED = 'task:unassigned', + TASK_HOLD = 'task:hold', + TASK_UNHOLD = 'task:unhold', + TASK_CONSULT_END = 'task:consultEnd', + TASK_CONSULT_QUEUE_CANCELLED = 'task:consultQueueCancelled', + TASK_CONSULT_QUEUE_FAILED = 'task:consultQueueFailed', + TASK_CONSULT_ACCEPTED = 'task:consultAccepted', + TASK_CONSULTING = 'task:consulting', + TASK_CONSULT_CREATED = 'task:consultCreated', + TASK_OFFER_CONSULT = 'task:offerConsult', + TASK_PAUSE = 'task:pause', + TASK_RESUME = 'task:resume', + TASK_END = 'task:end', + TASK_WRAPUP = 'task:wrapup', + TASK_WRAPPEDUP = 'task:wrappedup', + TASK_RECORDING_PAUSED = 'task:recordingPaused', + TASK_RECORDING_PAUSE_FAILED = 'task:recordingPauseFailed', + TASK_RECORDING_RESUMED = 'task:recordingResumed', + TASK_RECORDING_RESUME_FAILED = 'task:recordingResumeFailed', + TASK_REJECT = 'task:rejected', + TASK_HYDRATE = 'task:hydrate', + TASK_OFFER_CONTACT = 'task:offerContact', +} export type Interaction = { isFcManaged: boolean; diff --git a/packages/@webex/plugin-cc/src/types.ts b/packages/@webex/plugin-cc/src/types.ts index 6ac95a8eddd..d814f52b85d 100644 --- a/packages/@webex/plugin-cc/src/types.ts +++ b/packages/@webex/plugin-cc/src/types.ts @@ -246,6 +246,9 @@ export type AgentLogin = { loginOption: LoginOption; }; + +export type AgentDeviceUpdate = Pick; + export type RequestBody = | SubscribeRequest | Agent.Logout @@ -279,8 +282,22 @@ export type BuddyAgents = { state?: 'Available' | 'Idle'; }; +/** + * Generic CC SDK error containing structured details. + * details.data can be any structured object. + */ +export interface GenericError extends Error { + details: { + type: string; + orgId: string; + trackingId: string; + data: Record; + }; +} + export type StationLoginResponse = Agent.StationLoginSuccessResponse | Error; export type StationLogoutResponse = Agent.LogoutSuccess | Error; export type StationReLoginResponse = Agent.ReloginSuccess | Error; export type SetStateResponse = Agent.StateChangeSuccess | Error; export type BuddyAgentsResponse = Agent.BuddyAgentsSuccess | Error; +export type UpdateDeviceTypeResponse = Agent.DeviceTypeUpdateSuccess | Error; diff --git a/packages/@webex/plugin-cc/src/webex-config.ts b/packages/@webex/plugin-cc/src/webex-config.ts new file mode 100644 index 00000000000..b576320f192 --- /dev/null +++ b/packages/@webex/plugin-cc/src/webex-config.ts @@ -0,0 +1,15 @@ +import {MemoryStoreAdapter} from '@webex/webex-core'; + +export default { + hydra: process.env.HYDRA_SERVICE_URL || 'https://api.ciscospark.com/v1', + hydraServiceUrl: process.env.HYDRA_SERVICE_URL || 'https://api.ciscospark.com/v1', + credentials: {}, + device: { + validateDomains: true, + ephemeral: true, + }, + storage: { + boundedAdapter: MemoryStoreAdapter, + unboundedAdapter: MemoryStoreAdapter, + }, +}; diff --git a/packages/@webex/plugin-cc/src/webex.js b/packages/@webex/plugin-cc/src/webex.js new file mode 100644 index 00000000000..fa1c7344359 --- /dev/null +++ b/packages/@webex/plugin-cc/src/webex.js @@ -0,0 +1,28 @@ +import merge from 'lodash/merge'; +import WebexCore from '@webex/webex-core'; +import {Buffer} from 'safe-buffer'; +import '@webex/plugin-authorization'; +import '@webex/internal-plugin-mercury'; +import '@webex/plugin-logger'; +import '@webex/internal-plugin-support'; + +import './index'; + +import config from './webex-config'; + +if (!global.Buffer) { + global.Buffer = Buffer; +} + +const Webex = WebexCore.extend({ + webex: true, + version: PACKAGE_VERSION, +}); + +Webex.init = function init(attrs = {}) { + attrs.config = merge({}, config, attrs.config); + + return new Webex(attrs); +}; + +export default Webex; diff --git a/packages/@webex/plugin-cc/test/unit/spec/cc.ts b/packages/@webex/plugin-cc/test/unit/spec/cc.ts index 854b742a0e8..a59117c2373 100644 --- a/packages/@webex/plugin-cc/test/unit/spec/cc.ts +++ b/packages/@webex/plugin-cc/test/unit/spec/cc.ts @@ -8,7 +8,7 @@ import { } from '../../../src/types'; import ContactCenter from '../../../src/cc'; import MockWebex from '@webex/test-helper-mock-webex'; -import {StationLoginSuccess} from '../../../src/services/agent/types'; +import {StationLoginSuccess, AGENT_EVENTS} from '../../../src/services/agent/types'; import {SetStateResponse} from '../../../src/types'; import {AGENT, WEB_RTC_PREFIX} from '../../../src/services/constants'; import Services from '../../../src/services'; @@ -18,8 +18,6 @@ import LoggerProxy from '../../../src/logger-proxy'; import * as Utils from '../../../src/services/core/Utils'; import { CC_FILE, - AGENT_STATE_CHANGE, - AGENT_MULTI_LOGIN, OUTDIAL_DIRECTION, OUTBOUND_TYPE, ATTRIBUTES, @@ -36,7 +34,6 @@ import { METRIC_EVENT_NAMES } from '../../../src/metrics/constants'; import Mercury from '@webex/internal-plugin-mercury'; import WebexRequest from '../../../src/services/core/WebexRequest'; - jest.mock('../../../src/logger-proxy', () => ({ __esModule: true, default: { @@ -51,6 +48,7 @@ jest.mock('../../../src/services/config'); jest.mock('../../../src/services/core/websocket/WebSocketManager'); jest.mock('../../../src/services/core/websocket/connection-service'); jest.mock('../../../src/services/WebCallingService'); +jest.mock('uuid', () => ({v4: () => 'mock-tracking-uuid'})); global.URL.createObjectURL = jest.fn(() => 'blob:http://localhost:3000/12345'); @@ -575,12 +573,12 @@ describe('webex.cc', () => { // Simulate receiving a message event messageCallback(JSON.stringify(agentStateChangeEventData)); - expect(ccEmitSpy).toHaveBeenCalledWith(AGENT_STATE_CHANGE, agentStateChangeEventData.data); + expect(ccEmitSpy).toHaveBeenCalledWith(AGENT_EVENTS.AGENT_STATE_CHANGE, agentStateChangeEventData.data); // Simulate receiving a message event messageCallback(JSON.stringify(agentMultiLoginEventData)); - expect(ccEmitSpy).toHaveBeenCalledWith(AGENT_MULTI_LOGIN, agentMultiLoginEventData.data); + expect(ccEmitSpy).toHaveBeenCalledWith(AGENT_EVENTS.AGENT_MULTI_LOGIN, agentMultiLoginEventData.data); }); it('should not attempt mobius registration for LoginOption.BROWSER if webrtc is disabled', async () => { @@ -1513,4 +1511,209 @@ describe('webex.cc', () => { ); }); }); + + describe('handleWebSocketMessage events', () => { + let messageCallback; + let emitSpy; + + beforeEach(() => { + emitSpy = jest.spyOn(webex.cc, 'emit'); + messageCallback = mockWebSocketManager.on.mock.calls.find((c) => c[0] === 'message')[1]; + }); + + it('should emit AGENT_STATION_LOGIN_SUCCESS on CC_EVENTS.AGENT_STATION_LOGIN_SUCCESS with mapped payload', () => { + const channelsMap = {chat: ['c1','c2'], email: [], social: ['s1'], telephony: []}; + const payload = { + trackingId: 'track-123', + data: { + agentId: 'agent-id', + teamId: 'team-id', + siteId: 'site-id', + roles: ['role1', 'role2'], + channelsMap, + type: CC_EVENTS.AGENT_STATION_LOGIN_SUCCESS, + }, + type: CC_EVENTS.AGENT_STATION_LOGIN, + }; + + messageCallback(JSON.stringify(payload)); + + expect(emitSpy).toHaveBeenNthCalledWith( + 2, + AGENT_EVENTS.AGENT_STATION_LOGIN_SUCCESS, + { + agentId: 'agent-id', + teamId: 'team-id', + siteId: 'site-id', + roles: ['role1', 'role2'], + mmProfile: { + chat: 2, + email: 0, + social: 1, + telephony: 0, + }, + notifsTrackingId: 'track-123', + type: CC_EVENTS.AGENT_STATION_LOGIN_SUCCESS, + } + ); + }); + + it('should emit AGENT_RELOGIN_SUCCESS on CC_EVENTS.AGENT_RELOGIN_SUCCESS with mapped payload', () => { + const channelsMap = {chat: ['a','b'], email: [], social: ['x'], telephony: ['y','z']}; + const payload = { + trackingId: 'trk-relogin', + data: { + agentId: 'agent-re', + teamId: 'team-re', + siteId: 'site-re', + roles: ['r1','r2'], + channelsMap, + type: CC_EVENTS.AGENT_RELOGIN_SUCCESS, + }, + type: CC_EVENTS.AGENT_RELOGIN_SUCCESS, + }; + + messageCallback(JSON.stringify(payload)); + + expect(emitSpy).toHaveBeenNthCalledWith( + 2, + AGENT_EVENTS.AGENT_RELOGIN_SUCCESS, + { + agentId: 'agent-re', + teamId: 'team-re', + siteId: 'site-re', + roles: ['r1', 'r2'], + mmProfile: { + chat: 2, + email: 0, + social: 1, + telephony: 2, + }, + notifsTrackingId: 'trk-relogin', + type: CC_EVENTS.AGENT_RELOGIN_SUCCESS, + } + ); + }); + + [ + { ccEvent: CC_EVENTS.AGENT_STATION_LOGIN_FAILED, constant: AGENT_EVENTS.AGENT_STATION_LOGIN_FAILED }, + { ccEvent: CC_EVENTS.AGENT_LOGOUT_SUCCESS, constant: AGENT_EVENTS.AGENT_LOGOUT_SUCCESS }, + { ccEvent: CC_EVENTS.AGENT_LOGOUT_FAILED, constant: AGENT_EVENTS.AGENT_LOGOUT_FAILED }, + { ccEvent: CC_EVENTS.AGENT_DN_REGISTERED, constant: AGENT_EVENTS.AGENT_DN_REGISTERED }, + { ccEvent: CC_EVENTS.AGENT_STATE_CHANGE_SUCCESS, constant: AGENT_EVENTS.AGENT_STATE_CHANGE_SUCCESS }, + { ccEvent: CC_EVENTS.AGENT_STATE_CHANGE_FAILED, constant: AGENT_EVENTS.AGENT_STATE_CHANGE_FAILED }, + ].forEach(({ ccEvent, constant }) => { + it(`should emit ${constant} on ${ccEvent}`, () => { + const sample = { foo: 'bar', type: ccEvent }; + messageCallback(JSON.stringify({type: ccEvent, data: sample})); + expect(emitSpy).toHaveBeenCalledWith(constant, sample); + }); + }); + }); + + describe('updateAgentDeviceType', () => { + beforeEach(() => { + webex.cc.agentConfig = { + ...webex.cc.agentConfig, + currentTeamId: 'teamId', + } as any; + }); + + it('should logout then login and return AgentDeviceTypeUpdateSuccess type', async () => { + const data = {loginOption: LoginOption.EXTENSION, dialNumber: '98765'}; + const mockResp = { + eventType: 'AgentDesktopMessage', + agentId: 'agentId', + trackingId: 'track-1', + auxCodeId: 'aux-1', + teamId: 'teamId', + agentSessionId: 'sessId', + orgId: 'org-1', + interactionIds: ['i1'], + status: 'LoggedIn', + subStatus: 'Available', + siteId: 'site-1', + lastIdleCodeChangeTimestamp: 1, + lastStateChangeTimestamp: 2, + profileType: 'type', + mmProfile: {chat: 0, email: 0, social: 0, telephony: 0}, + dialNumber: '98765', + roles: ['role'], + supervisorSessionId: undefined, + notifsTrackingId: 'notif-1', + type: 'AgentDeviceTypeUpdateSuccess', + }; + + jest.spyOn(webex.cc, 'stationLogout').mockResolvedValue({}); + jest.spyOn(webex.cc, 'stationLogin').mockResolvedValue(mockResp as any); + + const result = await webex.cc.updateAgentDeviceType(data); + + expect(webex.cc.stationLogout).toHaveBeenCalledWith({logoutReason: 'User requested agent device change'}); + expect(webex.cc.stationLogin).toHaveBeenCalledWith({ + teamId: 'teamId', + loginOption: data.loginOption, + dialNumber: data.dialNumber, + }); + expect(result).toEqual(mockResp); + }); + + it('should track failure and throw when stationLogout fails', async () => { + const data = {loginOption: LoginOption.EXTENSION, dialNumber: '98765'}; + const err = new Error('logout failure'); + jest.spyOn(webex.cc, 'stationLogout').mockRejectedValue(err); + const metricSpy = jest.spyOn(mockMetricsManager, 'trackEvent'); + const logSpy = jest.spyOn(LoggerProxy, 'error'); + + await expect(webex.cc.updateAgentDeviceType(data)).rejects.toThrow(err); + + expect(metricSpy).toHaveBeenCalledWith( + METRIC_EVENT_NAMES.AGENT_DEVICE_TYPE_UPDATE_FAILED, + expect.objectContaining({loginType: data.loginOption}), + ['behavioral','business','operational'] + ); + expect(logSpy).toHaveBeenCalledWith( + `[WX_CC_SDK_mock-tracking-uuid] updateAgentDeviceType | error updating profile: ${err}`, + {module: CC_FILE, method: 'updateAgentDeviceType'} + ); + }); + + it('should track failure and throw when stationLogin fails', async () => { + const data = {loginOption: LoginOption.EXTENSION, dialNumber: '98765'}; + jest.spyOn(webex.cc, 'stationLogout').mockResolvedValue({}); + const loginErr = new Error('login failure'); + jest.spyOn(webex.cc, 'stationLogin').mockRejectedValue(loginErr); + const metricSpy = jest.spyOn(mockMetricsManager, 'trackEvent'); + const logSpy = jest.spyOn(LoggerProxy, 'error'); + + await expect(webex.cc.updateAgentDeviceType(data)).rejects.toThrow(loginErr); + + expect(metricSpy).toHaveBeenCalledWith( + METRIC_EVENT_NAMES.AGENT_DEVICE_TYPE_UPDATE_FAILED, + expect.objectContaining({loginType: data.loginOption}), + ['behavioral','business','operational'] + ); + expect(logSpy).toHaveBeenCalledWith( + `[WX_CC_SDK_mock-tracking-uuid] updateAgentDeviceType | error updating profile: ${loginErr}`, + {module: CC_FILE, method: 'updateAgentDeviceType'} + ); + }); + + it('should throw with detailed error when loginOption equals current device type', async () => { + const data = {loginOption: LoginOption.BROWSER, dialNumber: '11111'}; + webex.cc.webCallingService.loginOption = data.loginOption; + + await expect(webex.cc.updateAgentDeviceType(data)).rejects.toMatchObject({ + message: 'Will not proceed with device update as new Device type is same as current device type', + details: expect.objectContaining({ + type: 'Identical Device Change Failure', + trackingId: 'WX_CC_SDK_mock-tracking-uuid', + data: expect.objectContaining({ + agentId: webex.cc.agentConfig.agentId, + reason: 'Will not proceed with device update as new Device type is same as current device type', + }), + }), + }); + }); + }); }); diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/task/TaskManager.ts b/packages/@webex/plugin-cc/test/unit/spec/services/task/TaskManager.ts index 110eca6f284..512623a3b93 100644 --- a/packages/@webex/plugin-cc/test/unit/spec/services/task/TaskManager.ts +++ b/packages/@webex/plugin-cc/test/unit/spec/services/task/TaskManager.ts @@ -9,7 +9,6 @@ import Task from '../../../../../src/services/task'; import {TASK_EVENTS} from '../../../../../src/services/task/types'; import WebCallingService from '../../../../../src/services/WebCallingService'; import config from '../../../../../src/config'; -import {wrap} from 'module'; import {CC_TASK_EVENTS} from '../../../../../src/services/config/types'; describe('TaskManager', () => { @@ -382,6 +381,39 @@ describe('TaskManager', () => { ); }); + it('should emit TASK_END event on AGENT_INVITE_FAILED event', () => { + webSocketManagerMock.emit('message', JSON.stringify(initalPayload)); + + const taskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit'); + const payload = { + data: { + type: CC_EVENTS.AGENT_INVITE_FAILED, + agentId: '723a8ffb-a26e-496d-b14a-ff44fb83b64f', + eventTime: 1733211616959, + eventType: 'RoutingMessage', + interaction: {state: 'connected'}, + interactionId: taskId, + orgId: '6ecef209-9a34-4ed1-a07a-7ddd1dbe925a', + trackingId: '575c0ec2-618c-42af-a61c-53aeb0a221ee', + mediaResourceId: '0ae913a4-c857-4705-8d49-76dd3dde75e4', + destAgentId: 'ebeb893b-ba67-4f36-8418-95c7492b28c2', + owner: '723a8ffb-a26e-496d-b14a-ff44fb83b64f', + queueMgr: 'aqm', + }, + }; + + taskManager.getTask(taskId).updateTaskData(payload.data); + webSocketManagerMock.emit('message', JSON.stringify(payload)); + expect(taskEmitSpy).toHaveBeenCalledWith( + CC_EVENTS.AGENT_INVITE_FAILED, + { ...payload.data} + ); + expect(taskEmitSpy).toHaveBeenCalledWith( + TASK_EVENTS.TASK_END, + taskManager.getTask(taskId) + ); + }); + it('should emit TASK_HYDRATE event on AGENT_CONTACT event', () => { const payload = { data: { @@ -489,14 +521,18 @@ describe('TaskManager', () => { }; webSocketManagerMock.emit('message', JSON.stringify(initalPayload)); - const taskUpdateTaskDataSpy = jest.spyOn(taskManager.getTask(taskId), 'updateTaskData'); + const task = taskManager.getTask(taskId); + const taskUpdateTaskDataSpy = jest.spyOn(task, 'updateTaskData'); + const taskEmitSpy = jest.spyOn(task, 'emit'); + webSocketManagerMock.emit('message', JSON.stringify(payload)); + expect(taskUpdateTaskDataSpy).toHaveBeenCalledWith({ ...payload.data, isConsulted: false, }); - const task = taskManager.getTask(taskId); expect(task.data.isConsulted).toBe(false); + expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_CONSULT_CREATED, task); }); it('handle AGENT_OFFER_CONTACT event', () => { @@ -550,17 +586,21 @@ describe('TaskManager', () => { }; webSocketManagerMock.emit('message', JSON.stringify(initalPayload)); - taskManager.getTask(taskId).updateTaskData = jest.fn().mockImplementation((newData) => { - taskManager.getTask(taskId).data = {...newData, isConsulted: true}; - return taskManager.getTask(taskId); + const task = taskManager.getTask(taskId); + task.updateTaskData = jest.fn().mockImplementation((newData) => { + task.data = {...newData, isConsulted: true}; + return task; }); + const taskEmitSpy = jest.spyOn(task, 'emit'); webSocketManagerMock.emit('message', JSON.stringify(payload)); - expect(taskManager.getTask(taskId).updateTaskData).toHaveBeenCalledWith({ + + expect(task.updateTaskData).toHaveBeenCalledWith({ ...payload.data, isConsulted: true, }); - expect(taskManager.getTask(taskId).data.isConsulted).toBe(true); + expect(task.data.isConsulted).toBe(true); + expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_OFFER_CONSULT, task); }); it('should emit TASK_CONSULT_ACCEPTED event on AGENT_CONSULTING event', () => { @@ -747,10 +787,13 @@ describe('TaskManager', () => { }, }; - taskManager.taskCollection[taskId] = taskManager.getTask(taskId); + webSocketManagerMock.emit('message', JSON.stringify(initalPayload)); + const task = taskManager.getTask(taskId); + const taskEmitSpy = jest.spyOn(task, 'emit'); webSocketManagerMock.emit('message', JSON.stringify(payload)); + expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_WRAPPEDUP, task); expect(taskManager.getTask(taskId)).toBeUndefined(); }); @@ -1176,5 +1219,21 @@ describe('TaskManager', () => { expect(unregisterSpy).not.toHaveBeenCalled(); expect(cleanUpCallSpy).not.toHaveBeenCalled(); }); + + describe('should emit appropriate task events for recording events', () => { + ['PAUSED', 'PAUSE_FAILED', 'RESUMED', 'RESUME_FAILED'].forEach((suffix) => { + const ccEvent = CC_EVENTS[`CONTACT_RECORDING_${suffix}`]; + const taskEvent = TASK_EVENTS[`TASK_RECORDING_${suffix}`]; + it(`should emit ${taskEvent} on ${ccEvent} event`, () => { + const payload = {data: {...initalPayload.data, type: ccEvent}}; + webSocketManagerMock.emit('message', JSON.stringify(initalPayload)); + const task = taskManager.getTask(taskId); + const spy = jest.spyOn(task, 'emit'); + + webSocketManagerMock.emit('message', JSON.stringify(payload)); + expect(spy).toHaveBeenCalledWith(taskEvent, task); + }); + }); + }); }); diff --git a/packages/@webex/plugin-encryption/README.md b/packages/@webex/plugin-encryption/README.md index 673bea4a68b..4810bc63469 100644 --- a/packages/@webex/plugin-encryption/README.md +++ b/packages/@webex/plugin-encryption/README.md @@ -2,11 +2,11 @@ [![standard-readme compliant](https://img.shields.io/badge/readme%20style-standard-brightgreen.svg?style=flat-square)](https://github.com/RichardLitt/standard-readme) -> Encryption plugin for the Cisco Webex JS SDK. +# Encryption plugin for the Cisco Webex JS SDK - [Install](#install) - [Usage](#usage) -- [Development](#development) +- [API Docs and Sample App](#api-docs-and-sample-app) - [Sample Code](#sample-code) - [Contribute](#contribute) - [Maintainers](#maintainers) @@ -18,9 +18,24 @@ npm install --save @webex/plugin-encryption ``` +In addition to the module consumption via NPMJS, this module can also be consumed via our CDN. See the below examples of how to consume this via CDN: + +```html + + + + + + + + + + +``` + ## Usage -This is a plugin for the Cisco Webex JS SDK . Please see our [developer portal](https://developer.webex.com/) and the [API reference](https://webex.github.io/webex-js-sdk/plugin-encryption/) for full details. +This is a plugin for the Cisco Webex JS SDK. Please see our [developer portal](https://developer.webex.com/) and the [API reference](https://webex.github.io/webex-js-sdk/plugin-encryption/) for full details. ## API Docs and Sample App @@ -31,7 +46,7 @@ This is a plugin for the Cisco Webex JS SDK . Please see our [developer portal]( ## Sample Code ```typescript -import Webex from 'webex/plugin-encryption'; +import Webex from '@webex/plugin-encryption'; const webex = Webex.init({ credentials: { @@ -40,7 +55,7 @@ const webex = Webex.init({ }); webex.once('ready', () => { - webex.cypher.register().then(() => { + webex.cypher.register().then(async () => { try { const attachmentURL = 'https:/myfileurl.xyz/zzz/fileid?keyUri=somekeyuri&JWE=somejwe'; const options = { @@ -63,7 +78,7 @@ webex.cypher.deregister().then(() => { }); ``` -#### Development +## Development To use `webpack-dev-server` to load this package, run `yarn run samples:serve`. diff --git a/packages/@webex/plugin-encryption/babel.config.js b/packages/@webex/plugin-encryption/babel.config.js index 5867dc6a7be..5c46f5c9099 100644 --- a/packages/@webex/plugin-encryption/babel.config.js +++ b/packages/@webex/plugin-encryption/babel.config.js @@ -1,13 +1,15 @@ module.exports = { - "presets": [ + plugins: ['@webex/babel-config-legacy/inject-package-version'], + presets: [ [ - "@babel/preset-env", + '@babel/preset-env', { - "targets": { - "node": "current" - } - } + targets: { + node: 'current', + }, + }, ], - "@babel/preset-typescript" + '@babel/preset-typescript', ], -} + sourceMaps: true, +}; diff --git a/packages/@webex/plugin-encryption/developer-quickstart.md b/packages/@webex/plugin-encryption/developer-quickstart.md index 11a00528435..e82c280482b 100644 --- a/packages/@webex/plugin-encryption/developer-quickstart.md +++ b/packages/@webex/plugin-encryption/developer-quickstart.md @@ -71,27 +71,23 @@ async function decryptFile(webex, encryptedFileUrl, options, decryptedFileName, console.error('Error decrypting file:', error); } } - -const attachmentURL = 'https:/myfileurl.xyz/zzz/fileid?keyUri=somekeyuri&JWE=somejwe'; -const options = { - useFileService: false, - jwe: somejwe, // Provide the JWE here if not already present in the attachmentURL - keyUri: someKeyUri // Provide the keyURI here if not already present in the attachmentURL -}; - -await decryptFile(webex, attachmentURL, options, 'MyFile.png', 'image/png'); ``` ### Example Usage ```typescript const accessToken = 'YOUR_ACCESS_TOKEN'; -const encryptedFileUrl = 'https://example.com/encrypted-file'; +const attachmentURL = 'https://myfileurl.xyz/zzz/fileid?keyUri=somekeyuri&JWE=somejwe'; const decryptedFileName = 'my-decrypted-file.jpeg'; const mimeType = 'image/jpeg'; +const options = { + useFileService: false, + jwe: somejwe, // Provide the JWE here if not already present in the attachmentURL + keyUri: someKeyUri // Provide the keyURI here if not already present in the attachmentURL +}; -initializeWebex(accessToken).then((webex) => { - decryptFile(webex, encryptedFileUrl, decryptedFileName, mimeType); +initializeWebex(accessToken).then(async (webex) => { + await decryptFile(webex, attachmentURL, options, decryptedFileName, mimeType); }); ``` diff --git a/packages/@webex/plugin-encryption/jest.config.js b/packages/@webex/plugin-encryption/jest.config.js index 99ba2e7153a..759bdb1d03f 100644 --- a/packages/@webex/plugin-encryption/jest.config.js +++ b/packages/@webex/plugin-encryption/jest.config.js @@ -2,7 +2,9 @@ const config = require('@webex/jest-config-legacy'); const jestConfig = { rootDir: './', - transformIgnorePatterns: [], + transformIgnorePatterns: [ + '/node_modules/(?!(uuid)/)', // Transform `uuid` using Babel + ], testPathIgnorePatterns: ['/node_modules/', '/dist/'], testResultsProcessor: 'jest-junit', // Clear mocks in between tests by default diff --git a/packages/@webex/plugin-encryption/package.json b/packages/@webex/plugin-encryption/package.json index d592bdda760..5594ca4b672 100644 --- a/packages/@webex/plugin-encryption/package.json +++ b/packages/@webex/plugin-encryption/package.json @@ -8,13 +8,25 @@ }, "license": "https://github.com/webex/webex-js-sdk/blob/next/LICENSE.md", "contributors": [ - "Bharath Balan " + "Adhwaith Menon ", + "Bharath Balan ", + "Kesava Krishnan Madavan ", + "Priya Kesari ", + "Rajesh Kumar ", + "Ravi Chandra Sekhar Sarika ", + "Shreyas Sharma ", + "Sreekanth Narayanan " ], - "main": "dist/index.js", + "main": "dist/webex.js", + "devMain": "src/index.ts", + "exports": { + ".": "./dist/webex.js", + "./package": "./package.json" + }, "types": "dist/types/index.d.ts", "scripts": { "build": "yarn run -T tsc --declaration true --declarationDir ./dist/types", - "build:docs": "typedoc --out ../../../docs/plugin-encrption", + "build:docs": "typedoc --out ../../../docs/plugin-encryption", "build:src": "webex-legacy-tools build -dest \"./dist\" -src \"./src\" -js -ts -maps && yarn build", "deploy:npm": "yarn npm publish", "test:browser": "webex-legacy-tools test --integration --runner karma", @@ -51,6 +63,5 @@ }, "engines": { "node": ">=16" - }, - "devMain": "src/index.js" + } } diff --git a/packages/@webex/plugin-encryption/src/webex-config.ts b/packages/@webex/plugin-encryption/src/webex-config.ts new file mode 100644 index 00000000000..b576320f192 --- /dev/null +++ b/packages/@webex/plugin-encryption/src/webex-config.ts @@ -0,0 +1,15 @@ +import {MemoryStoreAdapter} from '@webex/webex-core'; + +export default { + hydra: process.env.HYDRA_SERVICE_URL || 'https://api.ciscospark.com/v1', + hydraServiceUrl: process.env.HYDRA_SERVICE_URL || 'https://api.ciscospark.com/v1', + credentials: {}, + device: { + validateDomains: true, + ephemeral: true, + }, + storage: { + boundedAdapter: MemoryStoreAdapter, + unboundedAdapter: MemoryStoreAdapter, + }, +}; diff --git a/packages/@webex/plugin-encryption/src/webex.js b/packages/@webex/plugin-encryption/src/webex.js new file mode 100644 index 00000000000..66d36919926 --- /dev/null +++ b/packages/@webex/plugin-encryption/src/webex.js @@ -0,0 +1,35 @@ +/*! + * Copyright (c) 2015-2025 Cisco Systems, Inc. See the LICENSE file. + */ + +// Note: this file is written using commonjs instead of import/export to +// simplify consumption by those less familiar with the current state of +// JavaScript modularization + +/* istanbul ignore else */ +if (!global._babelPolyfill) { + /* eslint global-require: [0] */ + require('@babel/polyfill'); +} + +require('@webex/plugin-authorization'); +require('@webex/internal-plugin-encryption'); // required +require('./index'); + +const merge = require('lodash/merge'); +const WebexCore = require('@webex/webex-core').default; + +const config = require('./webex-config'); + +const Webex = WebexCore.extend({ + webex: true, + version: PACKAGE_VERSION, +}); + +Webex.init = function init(attrs = {}) { + attrs.config = merge({}, config, attrs.config); // eslint-disable-line no-param-reassign + + return new Webex(attrs); +}; + +module.exports = Webex; diff --git a/packages/@webex/plugin-meetings/package.json b/packages/@webex/plugin-meetings/package.json index 2715754a1b8..020a204d9f8 100644 --- a/packages/@webex/plugin-meetings/package.json +++ b/packages/@webex/plugin-meetings/package.json @@ -62,8 +62,8 @@ }, "dependencies": { "@webex/common": "workspace:*", - "@webex/event-dictionary-ts": "^1.0.1688", - "@webex/internal-media-core": "2.14.7", + "@webex/event-dictionary-ts": "^1.0.1753", + "@webex/internal-media-core": "2.16.0", "@webex/internal-plugin-conversation": "workspace:*", "@webex/internal-plugin-device": "workspace:*", "@webex/internal-plugin-llm": "workspace:*", @@ -75,6 +75,7 @@ "@webex/media-helpers": "workspace:*", "@webex/plugin-people": "workspace:*", "@webex/plugin-rooms": "workspace:*", + "@webex/ts-sdp": "^1.8.1", "@webex/web-capabilities": "^1.4.0", "@webex/webex-core": "workspace:*", "ampersand-collection": "^2.0.2", 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 9aa62015e23..0047639b743 100644 --- a/packages/@webex/plugin-meetings/src/common/errors/webex-errors.ts +++ b/packages/@webex/plugin-meetings/src/common/errors/webex-errors.ts @@ -152,12 +152,19 @@ WebExMeetingsErrors[IceGatheringFailed.CODE] = IceGatheringFailed; * @extends WebexMeetingsError * @property {number} code - 30203 * @property {string} message - 'Failed to add media' + * @property {Error} [cause] - The underlying error that caused the failure */ class AddMediaFailed extends WebexMeetingsError { static CODE = 30203; + cause?: Error; - constructor() { + /** + * Creates a new AddMediaFailed error + * @param {Error} [cause] - The underlying error that caused the media addition to fail + */ + constructor(cause?: Error) { super(AddMediaFailed.CODE, 'Failed to add media'); + this.cause = cause; } } export {AddMediaFailed}; diff --git a/packages/@webex/plugin-meetings/src/constants.ts b/packages/@webex/plugin-meetings/src/constants.ts index 08233513c9f..6b8a1ca8de5 100644 --- a/packages/@webex/plugin-meetings/src/constants.ts +++ b/packages/@webex/plugin-meetings/src/constants.ts @@ -911,6 +911,7 @@ export enum SELF_POLICY { SUPPORT_NETWORK_BASED_RECORD = 'supportNetworkBasedRecord', SUPPORT_PREMISE_RECORD = 'supportPremiseRecord', SUPPORT_REALTIME_CLOSE_CAPTION = 'supportRealtimeCloseCaption', + SUPPORT_REALTIME_CLOSE_CAPTION_MANUAL = 'supportRealtimeCloseCaptionManual', SUPPORT_CHAT = 'supportChat', SUPPORT_DESKTOP_SHARE_REMOTE = 'supportDesktopShareRemote', SUPPORT_DESKTOP_SHARE = 'supportDesktopShare', @@ -978,6 +979,7 @@ export const DISPLAY_HINTS = { PRESENTER_CONTROL: 'PRESENTER_CONTROL', CAN_RENAME_SELF_AND_OBSERVED: 'CAN_RENAME_SELF_AND_OBSERVED', CAN_RENAME_OTHERS: 'CAN_RENAME_OTHERS', + MOVE_TO_LOBBY: 'MOVE_TO_LOBBY', // breakout session BREAKOUT_MANAGEMENT: 'BREAKOUT_MANAGEMENT', @@ -1166,96 +1168,6 @@ export const NETWORK_STATUS = { export type NETWORK_STATUS = Enum; -export const NETWORK_TYPE = { - VPN: 'vpn', - UNKNOWN: 'unknown', - WIFI: 'wifi', - ETHERNET: 'ethernet', -}; - -export const STATS = { - SEND_DIRECTION: 'send', - RECEIVE_DIRECTION: 'recv', - REMOTE: 'remote', - LOCAL: 'local', -}; - -export const MQA_STATS = { - MQA_SIZE: 120, // MQA is done on 60 second intervals by server def, add a buffer for missed events - CA_TYPE: 'MQA', - DEFAULT_IP: '0.0.0.0', - DEFAULT_SHARE_SENDER_STATS: { - common: { - common: { - direction: 'sendrecv', // TODO: parse from SDP and save globally - isMain: false, // always true for share sender - mariFecEnabled: false, // unavailable - mariRtxEnabled: false, // unavailable - mariLiteEnabled: false, // unavailable - mariQosEnabled: false, // unavailable - multistreamEnabled: false, // unavailable - }, - availableBitrate: 0, - dtlsBitrate: 0, // unavailable - dtlsPackets: 0, // unavailable - fecBitrate: 0, // unavailable - fecPackets: 0, // unavailable - maxBitrate: 0, // unavailable - queueDelay: 0, // unavailable - remoteJitter: 0, // unavailable - remoteLossRate: 0, - roundTripTime: 0, - rtcpBitrate: 0, // unavailable - rtcpPackets: 0, // unavailable - rtpBitrate: 0, // unavailable - rtpPackets: 0, - stunBitrate: 0, // unavailable - stunPackets: 0, // unavailable - transportType: 'UDP', // TODO: parse the transport type from the SDP and save globally - }, - streams: [ - { - common: { - codec: 'H264', // TODO: parse the codec from the SDP and save globally - duplicateSsci: 0, // unavailable - requestedBitrate: 0, // unavailable - requestedFrames: 0, // unavailable - rtpPackets: 0, - ssci: 0, // unavailable - transmittedBitrate: 0, - transmittedFrameRate: 0, - }, - h264CodecProfile: 'BP', // TODO: parse the profile level from h264 in the SDP and save globally - localConfigurationChanges: 0, // unavailable - remoteConfigurationChanges: 0, // unavailable - requestedFrameSize: 0, // unavailable - requestedKeyFrames: 0, // unavailable - transmittedFrameSize: 0, // unavailable - transmittedHeight: 0, - transmittedKeyFrames: 0, - transmittedWidth: 0, - }, - ], - }, - intervalMetadata: { - memoryUsage: { - cpuBitWidth: 0, - mainProcessMaximumMemoryBytes: 0, - osBitWidth: 0, - processAverageMemoryUsage: 0, - processMaximumMemoryBytes: 0, - processMaximumMemoryUsage: 0, - systemAverageMemoryUsage: 0, - systemMaximumMemoryUsage: 0, - }, - peerReflexiveIP: 'NULL', // TODO: save after ice trickling completes and use as a global variable - processAverageCPU: 0, - processMaximumCPU: 0, - systemAverageCPU: 0, - systemMaximumCPU: 0, - }, -}; - // ****** MEDIA QUALITY CONSTANTS ****** // // these values must match allowed values of RemoteQualityLevel from the @webex/internal-media-core lib diff --git a/packages/@webex/plugin-meetings/src/locus-info/selfUtils.ts b/packages/@webex/plugin-meetings/src/locus-info/selfUtils.ts index a8694c1a8dc..e2db5df6b1f 100644 --- a/packages/@webex/plugin-meetings/src/locus-info/selfUtils.ts +++ b/packages/@webex/plugin-meetings/src/locus-info/selfUtils.ts @@ -17,513 +17,517 @@ import { } from '../constants'; import ParameterError from '../common/errors/parameter'; -const SelfUtils: any = {}; const PSTN_DEVICE_TYPE = 'PROVISIONAL'; -/** - * parses the relevant values for self: muted, guest, moderator, mediaStatus, state, joinedWith, pstnDevices, creator, id - * @param {Object} self - * @param {String} deviceId - * @returns {undefined} - */ -SelfUtils.parse = (self: any, deviceId: string) => { - if (self) { - const joinedWith = self.devices.find((device) => deviceId === device.url); - const pstnDevices = self.devices.filter((device) => PSTN_DEVICE_TYPE === device.deviceType); +const SelfUtils = { + /** + * parses the relevant values for self: muted, guest, moderator, mediaStatus, state, joinedWith, pstnDevices, creator, id + * @param {Object} self + * @param {String} deviceId + * @returns {undefined} + */ + parse: (self: any, deviceId: string) => { + if (self) { + const joinedWith = self.devices.find((device) => deviceId === device.url); + const pstnDevices = self.devices.filter((device) => PSTN_DEVICE_TYPE === device.deviceType); + + return { + remoteVideoMuted: SelfUtils.getRemoteVideoMuted(self), + remoteMuted: SelfUtils.getRemoteMuted(self), + unmuteAllowed: SelfUtils.getUnmuteAllowed(self), + localAudioUnmuteRequested: SelfUtils.getLocalAudioUnmuteRequested(self), + localAudioUnmuteRequestedTimeStamp: SelfUtils.getLocalAudioUnmuteRequestedTimeStamp(self), + localAudioUnmuteRequired: SelfUtils.getLocalAudioUnmuteRequired(self), + lastModified: SelfUtils.getLastModified(self), + modifiedBy: SelfUtils.getModifiedBy(self), + guest: self.guest, + moderator: self.moderator, + // cumulative media stats + mediaStatus: SelfUtils.getStatus(self.status), + // TODO: what should be the status if user has refreshed the page, + // check the joinedWith parameter and communicate to the user + state: self.state, + // TODO: give a proper name . With same device as login or different login` + // Some times we might have joined with both mobile and web + joinedWith, + pstnDevices, + // current media stats is for the current device who has joined + currentMediaStatus: SelfUtils.getMediaStatus(joinedWith?.mediaSessions), + creator: self.isCreator, // check if its used, + selfId: self.id, + selfIdentity: SelfUtils.getSelfIdentity(self), + selfUrl: self.url, + removed: self.removed, + roles: SelfUtils.getRoles(self), + isUserUnadmitted: self.state === _IDLE_ && joinedWith?.intent?.type === _WAIT_, + layout: SelfUtils.getLayout(self), + canNotViewTheParticipantList: SelfUtils.canNotViewTheParticipantList(self), + isSharingBlocked: SelfUtils.isSharingBlocked(self), + breakoutSessions: SelfUtils.getBreakoutSessions(self), + breakout: SelfUtils.getBreakout(self), + interpretation: SelfUtils.getInterpretation(self), + brb: SelfUtils.getBrb(self), + }; + } - return { - remoteVideoMuted: SelfUtils.getRemoteVideoMuted(self), - remoteMuted: SelfUtils.getRemoteMuted(self), - unmuteAllowed: SelfUtils.getUnmuteAllowed(self), - localAudioUnmuteRequested: SelfUtils.getLocalAudioUnmuteRequested(self), - localAudioUnmuteRequestedTimeStamp: SelfUtils.getLocalAudioUnmuteRequestedTimeStamp(self), - localAudioUnmuteRequired: SelfUtils.getLocalAudioUnmuteRequired(self), - lastModified: SelfUtils.getLastModified(self), - modifiedBy: SelfUtils.getModifiedBy(self), - guest: self.guest, - moderator: self.moderator, - // cumulative media stats - mediaStatus: SelfUtils.getStatus(self.status), - // TODO: what should be the status if user has refreshed the page, - // check the joinedWith parameter and communicate to the user - state: self.state, - // TODO: give a proper name . With same device as login or different login` - // Some times we might have joined with both mobile and web - joinedWith, - pstnDevices, - // current media stats is for the current device who has joined - currentMediaStatus: SelfUtils.getMediaStatus(joinedWith?.mediaSessions), - creator: self.isCreator, // check if its used, - selfId: self.id, - selfIdentity: SelfUtils.getSelfIdentity(self), - selfUrl: self.url, - removed: self.removed, - roles: SelfUtils.getRoles(self), - isUserUnadmitted: self.state === _IDLE_ && joinedWith?.intent?.type === _WAIT_, - layout: SelfUtils.getLayout(self), - canNotViewTheParticipantList: SelfUtils.canNotViewTheParticipantList(self), - isSharingBlocked: SelfUtils.isSharingBlocked(self), - breakoutSessions: SelfUtils.getBreakoutSessions(self), - breakout: SelfUtils.getBreakout(self), - interpretation: SelfUtils.getInterpretation(self), - brb: SelfUtils.getBrb(self), - }; - } + return null; + }, - return null; -}; + getBreakoutSessions: (self) => self?.controls?.breakout?.sessions, + getBreakout: (self) => self?.controls?.breakout, + getInterpretation: (self) => self?.controls?.interpretation, + getBrb: (self) => self?.controls?.brb, -SelfUtils.getBreakoutSessions = (self) => self?.controls?.breakout?.sessions; -SelfUtils.getBreakout = (self) => self?.controls?.breakout; -SelfUtils.getInterpretation = (self) => self?.controls?.interpretation; -SelfUtils.getBrb = (self) => self?.controls?.brb; + getLayout: (self) => + Array.isArray(self?.controls?.layouts) ? self.controls.layouts[0].type : undefined, -SelfUtils.getLayout = (self) => - Array.isArray(self?.controls?.layouts) ? self.controls.layouts[0].type : undefined; + getRoles: (self) => + (self?.controls?.role?.roles || []).reduce((roles, role) => { + if (role.hasRole) { + roles.push(role.type); + } -SelfUtils.getRoles = (self) => - (self?.controls?.role?.roles || []).reduce((roles, role) => { - if (role.hasRole) { - roles.push(role.type); - } + return roles; + }, []), - return roles; - }, []); - -SelfUtils.canNotViewTheParticipantList = (self) => !!self?.canNotViewTheParticipantList; - -SelfUtils.isSharingBlocked = (self) => !!self?.isSharingBlocked; - -SelfUtils.getSelves = (oldSelf, newSelf, deviceId) => { - const previous = oldSelf && SelfUtils.parse(oldSelf, deviceId); - const current = newSelf && SelfUtils.parse(newSelf, deviceId); - const updates: any = {}; - - updates.isUserUnadmitted = SelfUtils.isUserUnadmitted(previous, current); - updates.isUserAdmitted = SelfUtils.isUserAdmitted(previous, current); - updates.isVideoMutedByOthersChanged = SelfUtils.videoMutedByOthersChanged(previous, current); - updates.isMutedByOthersChanged = SelfUtils.mutedByOthersChanged(previous, current); - updates.localAudioUnmuteRequestedByServer = SelfUtils.localAudioUnmuteRequestedByServer( - previous, - current - ); - updates.localAudioUnmuteRequiredByServer = SelfUtils.localAudioUnmuteRequiredByServer( - previous, - current - ); - updates.moderatorChanged = SelfUtils.moderatorChanged(previous, current); - updates.isRolesChanged = SelfUtils.isRolesChanged(previous, current); - updates.isMediaInactiveOrReleased = SelfUtils.wasMediaInactiveOrReleased(previous, current); - updates.isUserObserving = SelfUtils.isDeviceObserving(previous, current); - updates.layoutChanged = SelfUtils.layoutChanged(previous, current); - - updates.isMediaInactive = SelfUtils.isMediaInactive(previous, current); - updates.audioStateChange = - previous?.currentMediaStatus.audio !== current.currentMediaStatus.audio; - updates.videoStateChange = - previous?.currentMediaStatus.video !== current.currentMediaStatus.video; - updates.shareStateChange = - previous?.currentMediaStatus.share !== current.currentMediaStatus.share; - - updates.canNotViewTheParticipantListChanged = - previous?.canNotViewTheParticipantList !== current.canNotViewTheParticipantList; - updates.isSharingBlockedChanged = previous?.isSharingBlocked !== current.isSharingBlocked; - updates.breakoutsChanged = SelfUtils.breakoutsChanged(previous, current); - updates.interpretationChanged = SelfUtils.interpretationChanged(previous, current); - updates.brbChanged = SelfUtils.brbChanged(previous, current); - - return { - previous, - current, - updates, - }; -}; + canNotViewTheParticipantList: (self) => !!self?.canNotViewTheParticipantList, + + isSharingBlocked: (self) => !!self?.isSharingBlocked, + + getSelves: (oldSelf, newSelf, deviceId) => { + const previous = oldSelf && SelfUtils.parse(oldSelf, deviceId); + const current = newSelf && SelfUtils.parse(newSelf, deviceId); + const updates: any = {}; -/** - * Checks if user has joined the meeting - * @param {Object} self - * @returns {boolean} isJoined - */ -SelfUtils.isJoined = (self: any) => self?.state === _JOINED_; - -/** - * Validate if the Meeting Layout Controls Layout has changed. - * - * @param {Self} previous - Previous self state - * @param {Self} current - Current self state [per event] - * @returns {boolean} - If the Meeting Layout Controls Layout has changed. - */ -SelfUtils.layoutChanged = (previous: any, current: any) => - current?.layout && previous?.layout !== current?.layout; - -SelfUtils.breakoutsChanged = (previous, current) => - !isEqual(previous?.breakoutSessions, current?.breakoutSessions) && !!current?.breakout; - -SelfUtils.interpretationChanged = (previous, current) => - !isEqual(previous?.interpretation, current?.interpretation) && !!current?.interpretation; - -SelfUtils.brbChanged = (previous, current) => - !isEqual(previous?.brb, current?.brb) && current?.brb !== undefined; - -SelfUtils.isMediaInactive = (previous, current) => { - if ( - previous && - previous.joinedWith && - previous.joinedWith.mediaSessions && - current && - current.joinedWith && - current.joinedWith.mediaSessions - ) { - const previousMediaStatus = SelfUtils.getMediaStatus(previous.joinedWith.mediaSessions); - const currentMediaStatus = SelfUtils.getMediaStatus(current.joinedWith.mediaSessions); + updates.isUserUnadmitted = SelfUtils.isUserUnadmitted(previous, current); + updates.isUserAdmitted = SelfUtils.isUserAdmitted(previous, current); + updates.isVideoMutedByOthersChanged = SelfUtils.videoMutedByOthersChanged(previous, current); + updates.isMutedByOthersChanged = SelfUtils.mutedByOthersChanged(previous, current); + updates.localAudioUnmuteRequestedByServer = SelfUtils.localAudioUnmuteRequestedByServer( + previous, + current + ); + updates.localAudioUnmuteRequiredByServer = SelfUtils.localAudioUnmuteRequiredByServer( + previous, + current + ); + updates.moderatorChanged = SelfUtils.moderatorChanged(previous, current); + updates.isRolesChanged = SelfUtils.isRolesChanged(previous, current); + updates.isMediaInactiveOrReleased = SelfUtils.wasMediaInactiveOrReleased(previous, current); + updates.isUserObserving = SelfUtils.isDeviceObserving(previous, current); + updates.layoutChanged = SelfUtils.layoutChanged(previous, current); + + updates.isMediaInactive = SelfUtils.isMediaInactive(previous, current); + updates.audioStateChange = + previous?.currentMediaStatus.audio !== current.currentMediaStatus.audio; + updates.videoStateChange = + previous?.currentMediaStatus.video !== current.currentMediaStatus.video; + updates.shareStateChange = + previous?.currentMediaStatus.share !== current.currentMediaStatus.share; + + updates.canNotViewTheParticipantListChanged = + previous?.canNotViewTheParticipantList !== current.canNotViewTheParticipantList; + updates.isSharingBlockedChanged = previous?.isSharingBlocked !== current.isSharingBlocked; + updates.breakoutsChanged = SelfUtils.breakoutsChanged(previous, current); + updates.interpretationChanged = SelfUtils.interpretationChanged(previous, current); + updates.brbChanged = SelfUtils.brbChanged(previous, current); + return { + previous, + current, + updates, + }; + }, + + /** + * Checks if user has joined the meeting + * @param {Object} self + * @returns {boolean} isJoined + */ + isJoined: (self: any) => self?.state === _JOINED_, + + /** + * Validate if the Meeting Layout Controls Layout has changed. + * + * @param {Self} previous - Previous self state + * @param {Self} current - Current self state [per event] + * @returns {boolean} - If the Meeting Layout Controls Layout has changed. + */ + layoutChanged: (previous: any, current: any) => + current?.layout && previous?.layout !== current?.layout, + + breakoutsChanged: (previous, current) => + !isEqual(previous?.breakoutSessions, current?.breakoutSessions) && !!current?.breakout, + + interpretationChanged: (previous, current) => + !isEqual(previous?.interpretation, current?.interpretation) && !!current?.interpretation, + + brbChanged: (previous, current) => + !isEqual(previous?.brb, current?.brb) && current?.brb !== undefined, + + isMediaInactive: (previous, current) => { if ( - previousMediaStatus.audio && - currentMediaStatus.audio && - previousMediaStatus.audio.state !== MEDIA_STATE.inactive && - currentMediaStatus.audio.state === MEDIA_STATE.inactive && - currentMediaStatus.audio.direction !== MEDIA_STATE.inactive + previous && + previous.joinedWith && + previous.joinedWith.mediaSessions && + current && + current.joinedWith && + current.joinedWith.mediaSessions ) { - return true; + const previousMediaStatus = SelfUtils.getMediaStatus(previous.joinedWith.mediaSessions); + const currentMediaStatus = SelfUtils.getMediaStatus(current.joinedWith.mediaSessions); + + if ( + previousMediaStatus.audio && + currentMediaStatus.audio && + previousMediaStatus.audio.state !== MEDIA_STATE.inactive && + currentMediaStatus.audio.state === MEDIA_STATE.inactive && + currentMediaStatus.audio.direction !== MEDIA_STATE.inactive + ) { + return true; + } + + if ( + previousMediaStatus.video && + currentMediaStatus.video && + previousMediaStatus.video.state !== MEDIA_STATE.inactive && + currentMediaStatus.video.state === MEDIA_STATE.inactive && + currentMediaStatus.video.direction !== MEDIA_STATE.inactive + ) { + return true; + } + + if ( + previousMediaStatus.share && + currentMediaStatus.share && + previousMediaStatus.share.state !== MEDIA_STATE.inactive && + currentMediaStatus.share.state === MEDIA_STATE.inactive && + currentMediaStatus.share.direction !== MEDIA_STATE.inactive + ) { + return true; + } + + return false; } + return false; + }, + + getLastModified: (self) => { if ( - previousMediaStatus.video && - currentMediaStatus.video && - previousMediaStatus.video.state !== MEDIA_STATE.inactive && - currentMediaStatus.video.state === MEDIA_STATE.inactive && - currentMediaStatus.video.direction !== MEDIA_STATE.inactive + !self || + !self.controls || + !self.controls.audio || + !self.controls.audio.meta || + !self.controls.audio.meta.lastModified ) { - return true; + return null; } + return self.controls.audio.meta.lastModified; + }, + + getModifiedBy: (self) => { if ( - previousMediaStatus.share && - currentMediaStatus.share && - previousMediaStatus.share.state !== MEDIA_STATE.inactive && - currentMediaStatus.share.state === MEDIA_STATE.inactive && - currentMediaStatus.share.direction !== MEDIA_STATE.inactive + !self || + !self.controls || + !self.controls.audio || + !self.controls.audio.meta || + !self.controls.audio.meta.modifiedBy ) { - return true; + return null; } - return false; - } + return self.controls.audio.meta.modifiedBy; + }, + + /** + * get the id from the self object + * @param {Object} self + * @returns {String} + */ + getSelfIdentity: (self: any) => { + if (!self || !self.person) { + return null; + } - return false; -}; + return self.person.id; + }, + + /** + * get the "remote video mute" property from the self object + * @param {Object} self + * @returns {Boolean} + */ + getRemoteVideoMuted: (self: any) => { + if (!self || !self.controls || !self.controls.video) { + return null; + } -SelfUtils.getLastModified = (self) => { - if ( - !self || - !self.controls || - !self.controls.audio || - !self.controls.audio.meta || - !self.controls.audio.meta.lastModified - ) { - return null; - } + return self.controls.video.muted; + }, + + /** + * get the "remote mute" property from the self object + * @param {Object} self + * @returns {Boolean} + */ + getRemoteMuted: (self: any) => { + if (!self || !self.controls || !self.controls.audio) { + return null; + } - return self.controls.audio.meta.lastModified; -}; + return self.controls.audio.muted; + }, -SelfUtils.getModifiedBy = (self) => { - if ( - !self || - !self.controls || - !self.controls.audio || - !self.controls.audio.meta || - !self.controls.audio.meta.modifiedBy - ) { - return null; - } + getLocalAudioUnmuteRequested: (self) => !!self?.controls?.audio?.requestedToUnmute, - return self.controls.audio.meta.modifiedBy; -}; + // requestedToUnmute timestamp + getLocalAudioUnmuteRequestedTimeStamp: (self) => + Date.parse(self?.controls?.audio?.lastModifiedRequestedToUnmute) || 0, -/** - * get the id from the self object - * @param {Object} self - * @returns {String} - */ -SelfUtils.getSelfIdentity = (self: any) => { - if (!self && !self.person) { - return null; - } + getUnmuteAllowed: (self) => { + if (!self || !self.controls || !self.controls.audio) { + return null; + } - return self.person.id; -}; + return !self.controls.audio.disallowUnmute; + }, + + getLocalAudioUnmuteRequired: (self) => !!self?.controls?.audio?.localAudioUnmuteRequired, + + getStatus: (status) => ({ + audio: status.audioStatus, + video: status.videoStatus, + slides: status.videoSlidesStatus, + }), + + /** + * @param {Object} oldSelf + * @param {Object} changedSelf + * @returns {Boolean} + */ + wasMediaInactiveOrReleased: (oldSelf: any = {}, changedSelf: any) => + oldSelf.joinedWith && + oldSelf.joinedWith.state === _JOINED_ && + changedSelf.joinedWith && + changedSelf.joinedWith.state === _LEFT_ && + (changedSelf.joinedWith.reason === MEETING_END_REASON.INACTIVE || + changedSelf.joinedWith.reason === MEETING_END_REASON.MEDIA_RELEASED), + + /** + * @param {Object} check + * @returns {Boolean} + */ + isLocusUserUnadmitted: (check: any) => + check && check.joinedWith?.intent?.type === _WAIT_ && check.state === _IDLE_, + + /** + * @param {Object} check + * @returns {Boolean} + */ + isLocusUserAdmitted: (check: any) => + check && check.joinedWith?.intent?.type !== _WAIT_ && check.state === _JOINED_, + + /** + * @param {Object} oldSelf + * @param {Object} changedSelf + * @returns {Boolean} + * @throws {Error} when self is undefined + */ + isUserUnadmitted: (oldSelf: any, changedSelf: any) => { + if (!changedSelf) { + throw new ParameterError( + 'changedSelf must be defined to determine if self is unadmitted as guest.' + ); + } -/** - * get the "remote video mute" property from the self object - * @param {Object} self - * @returns {Boolean} - */ -SelfUtils.getRemoteVideoMuted = (self: any) => { - if (!self || !self.controls || !self.controls.video) { - return null; - } + if (SelfUtils.isLocusUserUnadmitted(oldSelf)) { + return false; + } - return self.controls.video.muted; -}; + return SelfUtils.isLocusUserUnadmitted(changedSelf); + }, -/** - * get the "remote mute" property from the self object - * @param {Object} self - * @returns {Boolean} - */ -SelfUtils.getRemoteMuted = (self: any) => { - if (!self || !self.controls || !self.controls.audio) { - return null; - } + moderatorChanged: (oldSelf, changedSelf) => { + if (!oldSelf) { + return true; + } + if (!changedSelf) { + throw new ParameterError( + 'New self must be defined to determine if self transitioned moderator status.' + ); + } - return self.controls.audio.muted; -}; + return oldSelf.moderator !== changedSelf.moderator; + }, + + /** + * determine whether the roles of self is changed or not + * @param {Object} oldSelf + * @param {Object} changedSelf + * @returns {Boolean} + */ + isRolesChanged: (oldSelf, changedSelf) => { + if (!changedSelf) { + // no new self means no change + return false; + } -SelfUtils.getLocalAudioUnmuteRequested = (self) => !!self?.controls?.audio?.requestedToUnmute; + return !isEqual(oldSelf?.roles, changedSelf?.roles); + }, + /** + * @param {Object} oldSelf + * @param {Object} changedSelf + * @returns {Boolean} + * @throws {Error} if changed self was undefined + */ + isDeviceObserving: (oldSelf: any, changedSelf: any) => + oldSelf && + oldSelf.joinedWith?.intent?.type === _MOVE_MEDIA_ && + changedSelf && + changedSelf.joinedWith?.intent?.type === _OBSERVE_, + + /** + * @param {Object} oldSelf + * @param {Object} changedSelf + * @returns {Boolean} + * @throws {Error} if changed self was undefined + */ + isUserAdmitted: (oldSelf: object, changedSelf: object) => { + if (!oldSelf) { + // if there was no previous locus, it couldn't have been admitted yet + return false; + } + if (!changedSelf) { + throw new ParameterError( + 'New self must be defined to determine if self transitioned to admitted as guest.' + ); + } -// requestedToUnmute timestamp -SelfUtils.getLocalAudioUnmuteRequestedTimeStamp = (self) => - Date.parse(self?.controls?.audio?.lastModifiedRequestedToUnmute) || 0; + return SelfUtils.isLocusUserUnadmitted(oldSelf) && SelfUtils.isLocusUserAdmitted(changedSelf); + }, -SelfUtils.getUnmuteAllowed = (self) => { - if (!self || !self.controls || !self.controls.audio) { - return null; - } + videoMutedByOthersChanged: (oldSelf, changedSelf) => { + if (!changedSelf) { + throw new ParameterError( + 'New self must be defined to determine if self was video muted by others.' + ); + } - return !self.controls.audio.disallowUnmute; -}; + if (!oldSelf || oldSelf.remoteVideoMuted === null) { + if (changedSelf.remoteVideoMuted) { + return true; // this happens when host disables "Allow start video" + } -SelfUtils.getLocalAudioUnmuteRequired = (self) => !!self?.controls?.audio?.localAudioUnmuteRequired; - -SelfUtils.getStatus = (status) => ({ - audio: status.audioStatus, - video: status.videoStatus, - slides: status.videoSlidesStatus, -}); - -/** - * @param {Object} oldSelf - * @param {Object} changedSelf - * @returns {Boolean} - */ -SelfUtils.wasMediaInactiveOrReleased = (oldSelf: any = {}, changedSelf: any) => - oldSelf.joinedWith && - oldSelf.joinedWith.state === _JOINED_ && - changedSelf.joinedWith && - changedSelf.joinedWith.state === _LEFT_ && - (changedSelf.joinedWith.reason === MEETING_END_REASON.INACTIVE || - changedSelf.joinedWith.reason === MEETING_END_REASON.MEDIA_RELEASED); - -/** - * @param {Object} check - * @returns {Boolean} - */ -SelfUtils.isLocusUserUnadmitted = (check: any) => - check && check.joinedWith?.intent?.type === _WAIT_ && check.state === _IDLE_; - -/** - * @param {Object} check - * @returns {Boolean} - */ -SelfUtils.isLocusUserAdmitted = (check: any) => - check && check.joinedWith?.intent?.type !== _WAIT_ && check.state === _JOINED_; - -/** - * @param {Object} oldSelf - * @param {Object} changedSelf - * @returns {Boolean} - * @throws {Error} when self is undefined - */ -SelfUtils.isUserUnadmitted = (oldSelf: object, changedSelf: object) => { - if (!changedSelf) { - throw new ParameterError( - 'changedSelf must be defined to determine if self is unadmitted as guest.' - ); - } + // we don't want to be sending the 'meeting:self:videoUnmutedByOthers' notification on meeting join + return false; + } - if (SelfUtils.isLocusUserUnadmitted(oldSelf)) { - return false; - } + return oldSelf.remoteVideoMuted !== changedSelf.remoteVideoMuted; + }, - return SelfUtils.isLocusUserUnadmitted(changedSelf); -}; + mutedByOthersChanged: (oldSelf, changedSelf) => { + if (!changedSelf) { + throw new ParameterError( + 'New self must be defined to determine if self was muted by others.' + ); + } -SelfUtils.moderatorChanged = (oldSelf, changedSelf) => { - if (!oldSelf) { - return true; - } - if (!changedSelf) { - throw new ParameterError( - 'New self must be defined to determine if self transitioned moderator status.' - ); - } + if (!oldSelf || oldSelf.remoteMuted === null) { + if (changedSelf.remoteMuted) { + return true; // this happens when mute on-entry is enabled + } - return oldSelf.moderator !== changedSelf.moderator; -}; + // we don't want to be sending the 'meeting:self:unmutedByOthers' notification on meeting join + return false; + } -/** - * determine whether the roles of self is changed or not - * @param {Object} oldSelf - * @param {Object} changedSelf - * @returns {Boolean} - */ -SelfUtils.isRolesChanged = (oldSelf, changedSelf) => { - if (!changedSelf) { - // no new self means no change - return false; - } + // there is no need to trigger user update if no one muted user + if (changedSelf.selfIdentity === changedSelf.modifiedBy) { + return false; + } - return !isEqual(oldSelf?.roles, changedSelf?.roles); -}; -/** - * @param {Object} oldSelf - * @param {Object} changedSelf - * @returns {Boolean} - * @throws {Error} if changed self was undefined - */ -SelfUtils.isDeviceObserving = (oldSelf: any, changedSelf: any) => - oldSelf && - oldSelf.joinedWith?.intent?.type === _MOVE_MEDIA_ && - changedSelf && - changedSelf.joinedWith?.intent?.type === _OBSERVE_; - -/** - * @param {Object} oldSelf - * @param {Object} changedSelf - * @returns {Boolean} - * @throws {Error} if changed self was undefined - */ -SelfUtils.isUserAdmitted = (oldSelf: object, changedSelf: object) => { - if (!oldSelf) { - // if there was no previous locus, it couldn't have been admitted yet - return false; - } - if (!changedSelf) { - throw new ParameterError( - 'New self must be defined to determine if self transitioned to admitted as guest.' + return ( + changedSelf.remoteMuted !== null && + (oldSelf.remoteMuted !== changedSelf.remoteMuted || + (changedSelf.remoteMuted && oldSelf.unmuteAllowed !== changedSelf.unmuteAllowed)) ); - } + }, - return SelfUtils.isLocusUserUnadmitted(oldSelf) && SelfUtils.isLocusUserAdmitted(changedSelf); -}; + localAudioUnmuteRequestedByServer: (oldSelf: any = {}, changedSelf: any) => { + if (!changedSelf) { + throw new ParameterError( + 'New self must be defined to determine if self received request to unmute.' + ); + } -SelfUtils.videoMutedByOthersChanged = (oldSelf, changedSelf) => { - if (!changedSelf) { - throw new ParameterError( - 'New self must be defined to determine if self was video muted by others.' + return ( + changedSelf.localAudioUnmuteRequested && + changedSelf.localAudioUnmuteRequestedTimeStamp > oldSelf.localAudioUnmuteRequestedTimeStamp ); - } + }, - if (!oldSelf || oldSelf.remoteVideoMuted === null) { - if (changedSelf.remoteVideoMuted) { - return true; // this happens when host disables "Allow start video" + localAudioUnmuteRequiredByServer: (oldSelf: any = {}, changedSelf: any) => { + if (!changedSelf) { + throw new ParameterError( + 'New self must be defined to determine if localAudioUnmuteRequired changed.' + ); } - // we don't want to be sending the 'meeting:self:videoUnmutedByOthers' notification on meeting join - return false; - } - - return oldSelf.remoteVideoMuted !== changedSelf.remoteVideoMuted; -}; - -SelfUtils.mutedByOthersChanged = (oldSelf, changedSelf) => { - if (!changedSelf) { - throw new ParameterError('New self must be defined to determine if self was muted by others.'); - } - - if (!oldSelf || oldSelf.remoteMuted === null) { - if (changedSelf.remoteMuted) { - return true; // this happens when mute on-entry is enabled + return ( + !changedSelf.remoteMuted && + changedSelf.localAudioUnmuteRequired && + oldSelf.localAudioUnmuteRequired !== changedSelf.localAudioUnmuteRequired + ); + }, + + /** + * extract the sipUrl from the partner + * @param {Object} partner + * @param {Object} info + * @returns {Object} + */ + + getSipUrl: (partner: any, type, sipUri) => { + // For webex meeting the sipUrl gets updated in info parser + if (partner && type === _CALL_) { + return {sipUri: partner.person.sipUrl || partner.person.id}; } - // we don't want to be sending the 'meeting:self:unmutedByOthers' notification on meeting join - return false; - } - - // there is no need to trigger user update if no one muted user - if (changedSelf.selfIdentity === changedSelf.modifiedBy) { - return false; - } + return {sipUri}; + }, - return ( - changedSelf.remoteMuted !== null && - (oldSelf.remoteMuted !== changedSelf.remoteMuted || - (changedSelf.remoteMuted && oldSelf.unmuteAllowed !== changedSelf.unmuteAllowed)) - ); -}; + getMediaStatus: (mediaSessions = []): {audio: any; video: any; share: any} => { + const mediaStatus = { + audio: {}, + video: {}, + share: {}, + }; -SelfUtils.localAudioUnmuteRequestedByServer = (oldSelf: any = {}, changedSelf: any) => { - if (!changedSelf) { - throw new ParameterError( - 'New self must be defined to determine if self received request to unmute.' + mediaStatus.audio = mediaSessions.find( + (media) => media.mediaType === AUDIO && media.mediaContent === MediaContent.main ); - } - - return ( - changedSelf.localAudioUnmuteRequested && - changedSelf.localAudioUnmuteRequestedTimeStamp > oldSelf.localAudioUnmuteRequestedTimeStamp - ); -}; - -SelfUtils.localAudioUnmuteRequiredByServer = (oldSelf: any = {}, changedSelf: any) => { - if (!changedSelf) { - throw new ParameterError( - 'New self must be defined to determine if localAudioUnmuteRequired changed.' + mediaStatus.video = mediaSessions.find( + (media) => media.mediaType === VIDEO && media.mediaContent === MediaContent.main + ); + mediaStatus.share = mediaSessions.find( + (media) => media.mediaType === VIDEO && media.mediaContent === MediaContent.slides ); - } - - return ( - !changedSelf.remoteMuted && - changedSelf.localAudioUnmuteRequired && - oldSelf.localAudioUnmuteRequired !== changedSelf.localAudioUnmuteRequired - ); -}; - -/** - * extract the sipUrl from the partner - * @param {Object} partner - * @param {Object} info - * @returns {Object} - */ - -SelfUtils.getSipUrl = (partner: any, type, sipUri) => { - // For webex meeting the sipUrl gets updated in info parser - if (partner && type === _CALL_) { - return {sipUri: partner.person.sipUrl || partner.person.id}; - } - - return {sipUri}; -}; -SelfUtils.getMediaStatus = (mediaSessions = []) => { - const mediaStatus = { - audio: {}, - video: {}, - share: {}, - }; - - mediaStatus.audio = mediaSessions.find( - (media) => media.mediaType === AUDIO && media.mediaContent === MediaContent.main - ); - mediaStatus.video = mediaSessions.find( - (media) => media.mediaType === VIDEO && media.mediaContent === MediaContent.main - ); - mediaStatus.share = mediaSessions.find( - (media) => media.mediaType === VIDEO && media.mediaContent === MediaContent.slides - ); - - return mediaStatus; -}; + return mediaStatus; + }, -SelfUtils.getReplacedBreakoutMoveId = (self: any, deviceId: string) => { - if (self && Array.isArray(self.devices)) { - const joinedDevice = self.devices.find((device) => deviceId === device.url); - if (Array.isArray(joinedDevice?.replaces)) { - return joinedDevice.replaces[0]?.breakoutMoveId; + getReplacedBreakoutMoveId: (self: any, deviceId: string) => { + if (self && Array.isArray(self.devices)) { + const joinedDevice = self.devices.find((device) => deviceId === device.url); + if (Array.isArray(joinedDevice?.replaces)) { + return joinedDevice.replaces[0]?.breakoutMoveId; + } } - } - return null; + return null; + }, }; + export default SelfUtils; diff --git a/packages/@webex/plugin-meetings/src/media/index.ts b/packages/@webex/plugin-meetings/src/media/index.ts index 6b0962f6329..a4072f9c14b 100644 --- a/packages/@webex/plugin-meetings/src/media/index.ts +++ b/packages/@webex/plugin-meetings/src/media/index.ts @@ -142,6 +142,7 @@ Media.createMediaConnection = ( turnServerInfo?: TurnServerInfo; bundlePolicy?: BundlePolicy; iceCandidatesTimeout?: number; + disableAudioMainDtx?: boolean; } ) => { const { @@ -153,6 +154,7 @@ Media.createMediaConnection = ( turnServerInfo, bundlePolicy, iceCandidatesTimeout, + disableAudioMainDtx, } = options; const iceServers = []; @@ -176,6 +178,10 @@ Media.createMediaConnection = ( config.bundlePolicy = bundlePolicy; } + if (disableAudioMainDtx !== undefined) { + config.disableAudioMainDtx = disableAudioMainDtx; + } + return new MultistreamRoapMediaConnection( config, meetingId, diff --git a/packages/@webex/plugin-meetings/src/media/properties.ts b/packages/@webex/plugin-meetings/src/media/properties.ts index 2b97f0a2e1b..4f16db77c98 100644 --- a/packages/@webex/plugin-meetings/src/media/properties.ts +++ b/packages/@webex/plugin-meetings/src/media/properties.ts @@ -7,6 +7,8 @@ import { RemoteStream, } from '@webex/media-helpers'; +import {parse} from '@webex/ts-sdp'; +import {ClientEvent} from '@webex/internal-plugin-metrics'; import {MEETINGS, QUALITY_LEVELS} from '../constants'; import LoggerProxy from '../common/logs/logger-proxy'; import MediaConnectionAwaiter from './MediaConnectionAwaiter'; @@ -20,6 +22,8 @@ export type MediaDirection = { receiveShare: boolean; }; +export type IPVersion = ClientEvent['payload']['ipVersion']; + /** * @class MediaProperties */ @@ -212,6 +216,91 @@ export default class MediaProperties { }; } + /** + * Checks if the given IP address is IPv6 + * @param {string} ip address to check + * @returns {boolean} true if the address is IPv6, false otherwise + */ + private isIPv6(ip: string): boolean { + return ip.includes(':'); + } + + /** Finds out if we connected using IPv4 or IPv6 + * @param {RTCPeerConnection} webrtcMediaConnection + * @param {Array} allStatsReports array of RTC stats reports + * @returns {string} IPVersion + */ + private getConnectionIpVersion( + webrtcMediaConnection: RTCPeerConnection, + allStatsReports: any[] + ): IPVersion | undefined { + const transports = allStatsReports.filter((report) => report.type === 'transport'); + + let selectedCandidatePair; + + if (transports.length > 0 && transports[0].selectedCandidatePairId) { + selectedCandidatePair = allStatsReports.find( + (report) => + report.type === 'candidate-pair' && report.id === transports[0].selectedCandidatePairId + ); + } else { + // Firefox doesn't have selectedCandidatePairId, but has selected property on the candidate pair + selectedCandidatePair = allStatsReports.find( + (report) => report.type === 'candidate-pair' && report.selected + ); + } + + if (selectedCandidatePair) { + const localCandidate = allStatsReports.find( + (report) => + report.type === 'local-candidate' && report.id === selectedCandidatePair.localCandidateId + ); + + if (localCandidate) { + if (localCandidate.address) { + return this.isIPv6(localCandidate.address) ? 'IPv6' : 'IPv4'; + } + + try { + // safari doesn't have address field on the candidate, so we have to use the port to look up the candidate in the SDP + const localSdp = webrtcMediaConnection.localDescription.sdp; + + const parsedSdp = parse(localSdp); + + for (const mediaLine of parsedSdp.avMedia) { + const matchingCandidate = mediaLine.iceInfo.candidates.find( + (candidate) => candidate.port === localCandidate.port + ); + if (matchingCandidate) { + return this.isIPv6(matchingCandidate.connectionAddress) ? 'IPv6' : 'IPv4'; + } + } + + LoggerProxy.logger.warn( + `Media:properties#getConnectionIpVersion --> failed to find local candidate in the SDP for port ${localCandidate.port}` + ); + } catch (error) { + LoggerProxy.logger.warn( + `Media:properties#getConnectionIpVersion --> error while trying to find candidate in local SDP:`, + error + ); + + return undefined; + } + } else { + LoggerProxy.logger.warn( + `Media:properties#getConnectionIpVersion --> failed to find local candidate "${selectedCandidatePair.localCandidateId}" in getStats() results` + ); + } + } else { + LoggerProxy.logger.warn( + `Media:properties#getConnectionIpVersion --> failed to find selected candidate pair in getStats() results (transports.length=${transports.length}, selectedCandidatePairId=${transports[0]?.selectedCandidatePairId})` + ); + } + + return undefined; + } + /** * Returns the type of a connection that has been established * It should be 'UDP' | 'TCP' | 'TURN-TLS' | 'TURN-TCP' | 'TURN-UDP' | 'unknown' @@ -284,6 +373,7 @@ export default class MediaProperties { */ async getCurrentConnectionInfo(): Promise<{ connectionType: string; + ipVersion?: IPVersion; selectedCandidatePairChanges: number; numTransports: number; }> { @@ -309,10 +399,15 @@ export default class MediaProperties { }); const connectionType = this.getConnectionType(allStatsReports); + const rtcPeerconnection = + this.webrtcMediaConnection.multistreamConnection?.pc.pc || + this.webrtcMediaConnection.mediaConnection?.pc; + const ipVersion = this.getConnectionIpVersion(rtcPeerconnection, allStatsReports); const {selectedCandidatePairChanges, numTransports} = this.getTransportInfo(allStatsReports); return { connectionType, + ipVersion, selectedCandidatePairChanges, numTransports, }; @@ -323,6 +418,7 @@ export default class MediaProperties { return { connectionType: 'unknown', + ipVersion: undefined, selectedCandidatePairChanges: -1, numTransports: 0, }; 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 10f53882488..853dac5a722 100644 --- a/packages/@webex/plugin-meetings/src/meeting/in-meeting-actions.ts +++ b/packages/@webex/plugin-meetings/src/meeting/in-meeting-actions.ts @@ -81,6 +81,8 @@ interface IInMeetingActions { canShareDesktop?: boolean; canShareContent?: boolean; canTransferFile?: boolean; + canRealtimeCloseCaption?: boolean; + canRealtimeCloseCaptionManual?: boolean; canChat?: boolean; canDoVideo?: boolean; canAnnotate?: boolean; @@ -104,6 +106,7 @@ interface IInMeetingActions { canDisableAnnotation?: boolean; canEnableRemoteDesktopControl?: boolean; canDisableRemoteDesktopControl?: boolean; + canMoveToLobby?: boolean; } /** @@ -254,6 +257,10 @@ export default class InMeetingActions implements IInMeetingActions { canTransferFile = null; + canRealtimeCloseCaption = null; + + canRealtimeCloseCaptionManual = null; + canChat = null; canDoVideo = null; @@ -300,6 +307,8 @@ export default class InMeetingActions implements IInMeetingActions { canDisableRemoteDesktopControl = null; + canMoveToLobby = null; + /** * Returns all meeting action options * @returns {Object} @@ -376,6 +385,8 @@ export default class InMeetingActions implements IInMeetingActions { canShareDesktop: this.canShareDesktop, canShareContent: this.canShareContent, canTransferFile: this.canTransferFile, + canRealtimeCloseCaption: this.canRealtimeCloseCaption, + canRealtimeCloseCaptionManual: this.canRealtimeCloseCaptionManual, canChat: this.canChat, canDoVideo: this.canDoVideo, canAnnotate: this.canAnnotate, @@ -399,6 +410,7 @@ export default class InMeetingActions implements IInMeetingActions { canDisableAnnotation: this.canDisableAnnotation, canEnableRemoteDesktopControl: this.canEnableRemoteDesktopControl, canDisableRemoteDesktopControl: this.canDisableRemoteDesktopControl, + canMoveToLobby: this.canMoveToLobby, }); /** diff --git a/packages/@webex/plugin-meetings/src/meeting/index.ts b/packages/@webex/plugin-meetings/src/meeting/index.ts index 69d9f025dce..f4492657167 100644 --- a/packages/@webex/plugin-meetings/src/meeting/index.ts +++ b/packages/@webex/plugin-meetings/src/meeting/index.ts @@ -100,7 +100,6 @@ import { MEETING_STATE_MACHINE, MEETING_STATE, MEETINGS, - MQA_STATS, NETWORK_STATUS, ONLINE, OFFLINE, @@ -164,6 +163,7 @@ import Member from '../member'; import {BrbState, createBrbState} from './brbState'; import MultistreamNotSupportedError from '../common/errors/multistream-not-supported-error'; import JoinForbiddenError from '../common/errors/join-forbidden-error'; +import {ReachabilityMetrics} from '../reachability/reachability.types'; // default callback so we don't call an undefined function, but in practice it should never be used const DEFAULT_ICE_PHASE_CALLBACK = () => 'JOIN_MEETING_FINAL'; @@ -262,6 +262,8 @@ type FetchMeetingInfoParams = { sendCAevents?: boolean; }; +type MediaReachabilityMetrics = ReachabilityMetrics & {isSubnetReachable: boolean}; + /** * MediaDirection * @typedef {Object} MediaDirection @@ -722,6 +724,7 @@ export default class Meeting extends StatelessWebexPlugin { private rtcMetrics?: RtcMetrics; private uploadLogsTimer?: ReturnType; private logUploadIntervalIndex: number; + private mediaServerIp: string; /** * @param {Object} attrs @@ -1598,6 +1601,19 @@ export default class Meeting extends StatelessWebexPlugin { * @memberof Meeting */ this.#isoLocalClientMeetingJoinTime = undefined; + + // We clear the error cache of CA events on every new meeting instance + // @ts-ignore - Fix type + this.webex.internal.newMetrics.callDiagnosticMetrics.clearErrorCache(); + + /** + * IP Address of the remote media server + * @instance + * @type {string} + * @private + * @memberof Meeting + */ + this.mediaServerIp = undefined; } /** @@ -4110,6 +4126,7 @@ export default class Meeting extends StatelessWebexPlugin { this.userDisplayHints ), canUserRenameOthers: MeetingUtil.canUserRenameOthers(this.userDisplayHints), + canMoveToLobby: MeetingUtil.canMoveToLobby(this.userDisplayHints), canMuteAll: ControlsOptionsUtil.hasHints({ requiredHints: [DISPLAY_HINTS.MUTE_ALL], displayHints: this.userDisplayHints, @@ -4244,6 +4261,14 @@ export default class Meeting extends StatelessWebexPlugin { requiredPolicies: [SELF_POLICY.SUPPORT_FILE_TRANSFER], policies: this.selfUserPolicies, }), + canRealtimeCloseCaption: ControlsOptionsUtil.hasPolicies({ + requiredPolicies: [SELF_POLICY.SUPPORT_REALTIME_CLOSE_CAPTION], + policies: this.selfUserPolicies, + }), + canRealtimeCloseCaptionManual: ControlsOptionsUtil.hasPolicies({ + requiredPolicies: [SELF_POLICY.SUPPORT_REALTIME_CLOSE_CAPTION_MANUAL], + policies: this.selfUserPolicies, + }), canChat: ControlsOptionsUtil.hasPolicies({ requiredPolicies: [SELF_POLICY.SUPPORT_CHAT], policies: this.selfUserPolicies, @@ -5856,22 +5881,11 @@ export default class Meeting extends StatelessWebexPlugin { this ); - const proxyError = new Proxy(error, { - // eslint-disable-next-line require-jsdoc - get(target, prop) { - if (prop === 'handledBySdk') { - return true; - } - - return Reflect.get(target, prop); - }, - }); - - joinFailed(proxyError); + joinFailed(error); this.deferJoin = undefined; - return Promise.reject(proxyError); + return Promise.reject(error); }) .then((join) => { // @ts-ignore - config coming from registerPlugin @@ -6340,6 +6354,11 @@ export default class Meeting extends StatelessWebexPlugin { ? MeetingsUtil.getMediaServer(roapMessage.sdp) : undefined; + const mediaServerIp = + roapMessage.messageType === 'ANSWER' + ? MeetingsUtil.getMediaServerIp(roapMessage.sdp) + : undefined; + if (this.isMultistream && mediaServer && mediaServer !== 'homer') { throw new MultistreamNotSupportedError( `Client asked for multistream backend (Homer), but got ${mediaServer} instead` @@ -6350,6 +6369,10 @@ export default class Meeting extends StatelessWebexPlugin { if (mediaServer) { this.mediaProperties.webrtcMediaConnection.mediaServer = mediaServer; } + + if (this.isMultistream && mediaServerIp) { + this.mediaServerIp = mediaServerIp; + } }; /** @@ -6807,20 +6830,20 @@ export default class Meeting extends StatelessWebexPlugin { * @memberof Meetings */ setupStatsAnalyzerEventHandlers = () => { - this.statsAnalyzer.on(StatsAnalyzerEventNames.MEDIA_QUALITY, (options) => { - // TODO: might have to send the same event to the developer - // Add ip address info if geo hint is present - // @ts-ignore fix type - options.data.intervalMetadata.peerReflexiveIP = - // @ts-ignore - this.webex.meetings.geoHintInfo?.clientAddress || - options.data.intervalMetadata.peerReflexiveIP || - MQA_STATS.DEFAULT_IP; + this.statsAnalyzer.on(StatsAnalyzerEventNames.MEDIA_QUALITY, (event) => { + // Add IP address from geoHintInfo if missing. + if (event.data.intervalMetadata.maskedPeerReflexiveIP === '0.0.0.0') { + // @ts-ignore fix type + const clientAddressFromGeoHint = this.webex.meetings.geoHintInfo?.clientAddress; + if (clientAddressFromGeoHint) { + event.data.intervalMetadata.maskedPeerReflexiveIP = + CallDiagnosticUtils.anonymizeIPAddress(clientAddressFromGeoHint); + } + } + // Count members that are in the meeting. const {members} = this.getMembers().membersCollection; - - // Count members that are in the meeting - options.data.intervalMetadata.meetingUserCount = Object.values(members).filter( + event.data.intervalMetadata.meetingUserCount = Object.values(members).filter( (member: Member) => member.isInMeeting ).length; @@ -6829,10 +6852,10 @@ export default class Meeting extends StatelessWebexPlugin { name: 'client.mediaquality.event', options: { meetingId: this.id, - networkType: options.data.networkType, + networkType: this.statsAnalyzer.getNetworkType(), }, payload: { - intervals: [options.data], + intervals: [event.data], }, }); }); @@ -6997,6 +7020,8 @@ export default class Meeting extends StatelessWebexPlugin { bundlePolicy, // @ts-ignore - config coming from registerPlugin iceCandidatesTimeout: this.config.iceCandidatesGatheringTimeout, + // @ts-ignore - config coming from registerPlugin + disableAudioMainDtx: this.config.experimental.disableAudioMainDtx, } ); @@ -7147,12 +7172,18 @@ export default class Meeting extends StatelessWebexPlugin { }, options: { meetingId: this.id, + rawError: error, }, }); } - throw new Error( + + const timedOutError = new Error( `Timed out waiting for media connection to be connected, correlationId=${this.correlationId}` ); + + timedOutError.cause = error; + + throw timedOutError; } } @@ -7213,6 +7244,9 @@ export default class Meeting extends StatelessWebexPlugin { ROAP_OFFER_ANSWER_EXCHANGE_TIMEOUT / 1000 } seconds` ); + + const error = new Error('Timed out waiting for REMOTE SDP ANSWER'); + // @ts-ignore this.webex.internal.newMetrics.submitClientEvent({ name: 'client.media-engine.remote-sdp-received', @@ -7225,10 +7259,10 @@ export default class Meeting extends StatelessWebexPlugin { }), ], }, - options: {meetingId: this.id, rawError: new Error('Timeout waiting for SDP answer')}, + options: {meetingId: this.id, rawError: error}, }); - deferSDPAnswer.reject(new Error('Timed out waiting for REMOTE SDP ANSWER')); + deferSDPAnswer.reject(error); }, ROAP_OFFER_ANSWER_EXCHANGE_TIMEOUT); LoggerProxy.logger.info(`${LOG_HEADER} waiting for REMOTE SDP ANSWER...`); @@ -7333,7 +7367,7 @@ export default class Meeting extends StatelessWebexPlugin { error ); - throw new AddMediaFailed(); + throw new AddMediaFailed(error); } } @@ -7746,28 +7780,33 @@ export default class Meeting extends StatelessWebexPlugin { await this.enqueueScreenShareFloorRequest(); } - const {connectionType, selectedCandidatePairChanges, numTransports} = + const {connectionType, ipVersion, selectedCandidatePairChanges, numTransports} = await this.mediaProperties.getCurrentConnectionInfo(); - // @ts-ignore - const reachabilityStats = await this.webex.meetings.reachability.getReachabilityMetrics(); + const iceCandidateErrors = Object.fromEntries(this.iceCandidateErrors); + const reachabilityMetrics = await this.getMediaReachabilityMetricFields(); + Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.ADD_MEDIA_SUCCESS, { correlation_id: this.correlationId, locus_id: this.locusUrl.split('/').pop(), connectionType, + ipVersion, selectedCandidatePairChanges, numTransports, isMultistream: this.isMultistream, retriedWithTurnServer: this.addMediaData.retriedWithTurnServer, isJoinWithMediaRetry: this.joinWithMediaRetryInfo.isRetry, - ...reachabilityStats, + ...reachabilityMetrics, ...iceCandidateErrors, iceCandidatesCount: this.iceCandidatesCount, }); // @ts-ignore this.webex.internal.newMetrics.submitClientEvent({ name: 'client.media-engine.ready', + payload: { + ipVersion, + }, options: { meetingId: this.id, }, @@ -7783,7 +7822,7 @@ export default class Meeting extends StatelessWebexPlugin { LoggerProxy.logger.error(`${LOG_HEADER} failed to establish media connection: `, error); // @ts-ignore - const reachabilityMetrics = await this.webex.meetings.reachability.getReachabilityMetrics(); + const reachabilityMetrics = await this.getMediaReachabilityMetricFields(); const {selectedCandidatePairChanges, numTransports} = await this.mediaProperties.getCurrentConnectionInfo(); @@ -9589,4 +9628,44 @@ export default class Meeting extends StatelessWebexPlugin { return Promise.resolve(); } + + /** + * Gets the media reachability metrics + * + * @returns {Promise} + */ + private async getMediaReachabilityMetricFields(): Promise { + const reachabilityMetrics: ReachabilityMetrics = + // @ts-ignore + await this.webex.meetings.reachability.getReachabilityMetrics(); + + const successKeys: Array = [ + 'reachability_public_udp_success', + 'reachability_public_tcp_success', + 'reachability_public_xtls_success', + 'reachability_vmn_udp_success', + 'reachability_vmn_tcp_success', + 'reachability_vmn_xtls_success', + ]; + + const totalSuccessCases = successKeys.reduce((total, key) => { + const value = reachabilityMetrics[key]; + if (typeof value === 'number') { + return total + value; + } + + return total; + }, 0); + + let isSubnetReachable = null; + if (totalSuccessCases > 0) { + // @ts-ignore + isSubnetReachable = this.webex.meetings.reachability.isSubnetReachable(this.mediaServerIp); + } + + return { + ...reachabilityMetrics, + isSubnetReachable, + }; + } } diff --git a/packages/@webex/plugin-meetings/src/meeting/util.ts b/packages/@webex/plugin-meetings/src/meeting/util.ts index 4e2423759a6..a7856438511 100644 --- a/packages/@webex/plugin-meetings/src/meeting/util.ts +++ b/packages/@webex/plugin-meetings/src/meeting/util.ts @@ -583,6 +583,8 @@ const MeetingUtil = { displayHints.includes(DISPLAY_HINTS.SHARE_WHITEBOARD) && !!policies[SELF_POLICY.SUPPORT_WHITEBOARD], + canMoveToLobby: (displayHints) => displayHints.includes(DISPLAY_HINTS.MOVE_TO_LOBBY), + /** * Adds the current locus sequence information to a request body * @param {Object} meeting The meeting object diff --git a/packages/@webex/plugin-meetings/src/meetings/index.ts b/packages/@webex/plugin-meetings/src/meetings/index.ts index 5a498e89b41..a9b13ab2861 100644 --- a/packages/@webex/plugin-meetings/src/meetings/index.ts +++ b/packages/@webex/plugin-meetings/src/meetings/index.ts @@ -807,6 +807,26 @@ export default class Meetings extends WebexPlugin { } } + /** + * API to toggle usage of audio main DTX, needs to be called before webex.meetings.register() + * + * @param {Boolean} newValue + * @private + * @memberof Meetings + * @returns {undefined} + */ + private _toggleDisableAudioMainDtx(newValue: boolean) { + if (typeof newValue !== 'boolean') { + return; + } + + // @ts-ignore + if (this.config.experimental.disableAudioMainDtx !== newValue) { + // @ts-ignore + this.config.experimental.disableAudioMainDtx = newValue; + } + } + /** * Executes a registration step and updates the registration status. * @param {Function} step - The registration step to execute. @@ -935,6 +955,21 @@ export default class Meetings extends WebexPlugin { .disconnect() // @ts-ignore .then(() => this.webex.internal.device.unregister()) + .catch((error) => { + // If error status code is 404, continue the chain + if (error.statusCode === 404) { + LoggerProxy.logger.info( + 'Meetings:index#unregister --> 404 error during device unregister, proceeding normally' + ); + + return; // returning undefined allows the chain to continue + } + // For any other status code, break the chain by rethrowing + LoggerProxy.logger.error( + `Meetings:index#unregister --> Failed to unregister device: ${error.message}` + ); + throw error; // rethrow to break the promise chain + }) .then(() => { Trigger.trigger( this, diff --git a/packages/@webex/plugin-meetings/src/meetings/util.ts b/packages/@webex/plugin-meetings/src/meetings/util.ts index 47c38d6d713..06abea9bde9 100644 --- a/packages/@webex/plugin-meetings/src/meetings/util.ts +++ b/packages/@webex/plugin-meetings/src/meetings/util.ts @@ -99,6 +99,24 @@ MeetingsUtil.getMediaServer = (sdp) => { return mediaServer; }; +MeetingsUtil.getMediaServerIp = (sdp) => { + let mediaServerIp; + + // Attempt to collect the media server from the roap message. + try { + mediaServerIp = sdp + .split('\r\n') + .find((line) => line.startsWith('o=')) + .match(/o=\S+ \d+ \d+ IN IP4 ([\d.]+)/)?.[1] + .toLowerCase() + .trim(); + } catch { + mediaServerIp = undefined; + } + + return mediaServerIp; +}; + MeetingsUtil.checkForCorrelationId = (deviceUrl, locus) => { let devices = []; diff --git a/packages/@webex/plugin-meetings/src/members/index.ts b/packages/@webex/plugin-meetings/src/members/index.ts index f709100b3a4..fb8a3cbe52e 100644 --- a/packages/@webex/plugin-meetings/src/members/index.ts +++ b/packages/@webex/plugin-meetings/src/members/index.ts @@ -886,6 +886,31 @@ export default class Members extends StatelessWebexPlugin { }); } + /** + * Moves a meeting member into the lobby. + * @param {String} memberId -- The ID of the member to move. + * @returns {Promise} -- Resolves with the lobby‐move response. + * @public + * @memberof Members + */ + public moveToLobby(memberId: string) { + if (!this.locusUrl) { + return Promise.reject( + new ParameterError( + 'The associated locus url for this meetings members object must be defined.' + ) + ); + } + if (!memberId) { + return Promise.reject( + new ParameterError('The member id must be defined to move the member to lobby.') + ); + } + const body = MembersUtil.getMoveMemberToLobbyRequestBody(memberId); + + return this.membersRequest.moveToLobbyMember({locusUrl: this.locusUrl, memberId}, body); + } + /** * Raise or lower the hand of a member in a meeting * @param {String} memberId diff --git a/packages/@webex/plugin-meetings/src/members/request.ts b/packages/@webex/plugin-meetings/src/members/request.ts index eb8e7162766..b35b0b0bf56 100644 --- a/packages/@webex/plugin-meetings/src/members/request.ts +++ b/packages/@webex/plugin-meetings/src/members/request.ts @@ -129,6 +129,32 @@ export default class MembersRequest extends StatelessWebexPlugin { return this.locusDeltaRequest(requestParams); } + /** + * Sends a request to move a meeting member into the lobby. + * * + * @param {Object} options - Request options. + * @param {string} options.locusUrl - The locus URL for the meeting. + * @param {string} options.memberId - The ID of the member to move. + * @param {Object} body - The request payload. + * @param {Object} body.moveToLobby - Container for move‐to‐lobby data. + * @param {string[]} body.moveToLobby.participantIds - Array of participant IDs to move. + * @returns {Promise} - Resolves with the locus‐delta response. + */ + moveToLobbyMember( + options: {locusUrl: string; memberId: string}, + body: {moveToLobby: {participantIds: string[]}} + ) { + if (!options || !options.locusUrl || !options.memberId) { + throw new ParameterError( + 'memberId must be defined, and the associated locus url for this meeting object must be defined.' + ); + } + + const requestParams = MembersUtil.getMoveMemberToLobbyRequestParams(options, body); + + return this.locusDeltaRequest(requestParams); + } + /** * Sends a request to raise or lower a member's hand * @param {Object} options diff --git a/packages/@webex/plugin-meetings/src/members/util.ts b/packages/@webex/plugin-meetings/src/members/util.ts index 930d187b2d7..39db0ad4836 100644 --- a/packages/@webex/plugin-meetings/src/members/util.ts +++ b/packages/@webex/plugin-meetings/src/members/util.ts @@ -203,6 +203,22 @@ const MembersUtil = { }; }, + getMoveMemberToLobbyRequestBody: (memberId: string) => ({ + moveToLobby: { + participantIds: [memberId], + }, + }), + + getMoveMemberToLobbyRequestParams: (options: {memberId: string; locusUrl: string}, body) => { + const uri = `${options.locusUrl}/${PARTICIPANT}/${options.memberId}/${CONTROLS}`; + + return { + method: HTTP_VERBS.PATCH, + uri, + body, + }; + }, + /** * @param {ServerRoleShape} role * @returns {ServerRoleShape} the role shape to be added to the body diff --git a/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts b/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts index 8e2fda20388..d91de905588 100644 --- a/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts +++ b/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts @@ -49,6 +49,7 @@ export class ClusterReachability extends EventsScope { private srflxIceCandidates: RTCIceCandidate[] = []; public readonly isVideoMesh: boolean; public readonly name; + public readonly reachedSubnets: Set = new Set(); /** * Constructor for ClusterReachability @@ -234,27 +235,13 @@ export class ClusterReachability extends EventsScope { */ private registerIceGatheringStateChangeListener() { this.pc.onicegatheringstatechange = () => { - const {COMPLETE} = ICE_GATHERING_STATE; - - if (this.pc.iceConnectionState === COMPLETE) { + if (this.pc.iceGatheringState === ICE_GATHERING_STATE.COMPLETE) { this.closePeerConnection(); this.finishReachabilityCheck(); } }; } - /** - * Checks if we have the results for all the protocols (UDP and TCP) - * - * @returns {boolean} true if we have all results, false otherwise - */ - private haveWeGotAllResults(): boolean { - return ['udp', 'tcp', 'xtls'].every( - (protocol) => - this.result[protocol].result === 'reachable' || this.result[protocol].result === 'untested' - ); - } - /** * Saves the latency in the result for the given protocol and marks it as reachable, * emits the "resultReady" event if this is the first result for that protocol, @@ -264,9 +251,15 @@ export class ClusterReachability extends EventsScope { * @param {string} protocol * @param {number} latency * @param {string|null} [publicIp] + * @param {string|null} [serverIp] * @returns {void} */ - private saveResult(protocol: 'udp' | 'tcp' | 'xtls', latency: number, publicIp?: string | null) { + private saveResult( + protocol: 'udp' | 'tcp' | 'xtls', + latency: number, + publicIp?: string | null, + serverIp?: string | null + ) { const result = this.result[protocol]; if (result.latencyInMilliseconds === undefined) { @@ -294,6 +287,10 @@ export class ClusterReachability extends EventsScope { } else { this.addPublicIP(protocol, publicIp); } + + if (serverIp) { + this.reachedSubnets.add(serverIp); + } } /** @@ -351,21 +348,25 @@ export class ClusterReachability extends EventsScope { if (e.candidate) { if (e.candidate.type === CANDIDATE_TYPES.SERVER_REFLEXIVE) { - this.saveResult('udp', latencyInMilliseconds, e.candidate.address); + let serverIp = null; + if ('url' in e.candidate) { + const stunServerUrlRegex = /stun:([\d.]+):\d+/; + + const match = (e.candidate as any).url.match(stunServerUrlRegex); + if (match) { + // eslint-disable-next-line prefer-destructuring + serverIp = match[1]; + } + } + + this.saveResult('udp', latencyInMilliseconds, e.candidate.address, serverIp); this.determineNatType(e.candidate); } if (e.candidate.type === CANDIDATE_TYPES.RELAY) { const protocol = e.candidate.port === TURN_TLS_PORT ? 'xtls' : 'tcp'; - this.saveResult(protocol, latencyInMilliseconds); - // we don't add public IP for TCP, because in the case of relay candidates - // e.candidate.address is the TURN server address, not the client's public IP - } - - if (this.haveWeGotAllResults()) { - this.closePeerConnection(); - this.finishReachabilityCheck(); + this.saveResult(protocol, latencyInMilliseconds, null, e.candidate.address); } } }; diff --git a/packages/@webex/plugin-meetings/src/reachability/index.ts b/packages/@webex/plugin-meetings/src/reachability/index.ts index 5d6ff32f17c..73820cde41b 100644 --- a/packages/@webex/plugin-meetings/src/reachability/index.ts +++ b/packages/@webex/plugin-meetings/src/reachability/index.ts @@ -138,6 +138,60 @@ export default class Reachability extends EventsScope { } } + /** + * Checks if the given subnet is reachable + * @param {string} mediaServerIp - media server ip + * @returns {boolean | null} true if reachable, false if not reachable, null if mediaServerIp is not provided + * @public + * @memberof Reachability + */ + public isSubnetReachable(mediaServerIp?: string): boolean | null { + if (!mediaServerIp) { + LoggerProxy.logger.error(`Reachability:index#isSubnetReachable --> mediaServerIp is null`); + + return null; + } + + const subnetFirstOctet = mediaServerIp.split('.')[0]; + + LoggerProxy.logger.info( + `Reachability:index#isSubnetReachable --> Looking for subnet: ${subnetFirstOctet}.X.X.X` + ); + + const matchingReachedClusters = Object.values(this.clusterReachability).reduce( + (acc, cluster) => { + const reachedSubnetsArray = Array.from(cluster.reachedSubnets); + + let logMessage = `Reachability:index#isSubnetReachable --> Cluster ${cluster.name} reached [`; + for (let i = 0; i < reachedSubnetsArray.length; i += 1) { + const subnet = reachedSubnetsArray[i]; + const reachedSubnetFirstOctet = subnet.split('.')[0]; + + if (subnetFirstOctet === reachedSubnetFirstOctet) { + acc.add(cluster.name); + } + + logMessage += `${subnet}`; + if (i < reachedSubnetsArray.length - 1) { + logMessage += ','; + } + } + logMessage += `]`; + + LoggerProxy.logger.info(logMessage); + + return acc; + }, + new Set() + ); + + LoggerProxy.logger.info( + `Reachability:index#isSubnetReachable --> Found ${matchingReachedClusters.size} clusters that use the subnet ${subnetFirstOctet}.X.X.X` + ); + + return matchingReachedClusters.size > 0; + } + /** * Gets a list of media clusters from the backend and performs reachability checks on all the clusters * @param {string} trigger - explains the reason for starting reachability @@ -288,7 +342,7 @@ export default class Reachability extends EventsScope { {} ); this.sendMetric(true); - this.resolveReachabilityPromise(); + this.resolveReachabilityPromise(false); } } 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 80e035ac6fd..c132dbb47ad 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/media/index.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/media/index.ts @@ -159,6 +159,7 @@ describe('createMediaConnection', () => { password: 'turn password', }, bundlePolicy: 'max-bundle', + disableAudioMainDtx: false, }); assert.calledOnce(multistreamRoapMediaConnectionConstructorStub); assert.calledWith( @@ -172,6 +173,7 @@ describe('createMediaConnection', () => { }, ], bundlePolicy: 'max-bundle', + disableAudioMainDtx: false, }, 'meeting id' ); @@ -262,6 +264,34 @@ describe('createMediaConnection', () => { ); }); + it('does not pass disableAudioMainDtx to MultistreamRoapMediaConnection if disableAudioMainDtx is undefined', () => { + const multistreamRoapMediaConnectionConstructorStub = sinon + .stub(InternalMediaCoreModule, 'MultistreamRoapMediaConnection') + .returns(fakeRoapMediaConnection); + + Media.createMediaConnection(true, 'debug string', 'meeting id', { + mediaProperties: { + mediaDirection: { + sendAudio: true, + sendVideo: true, + sendShare: false, + receiveAudio: true, + receiveVideo: true, + receiveShare: true, + }, + }, + disableAudioMainDtx: undefined, + }); + assert.calledOnce(multistreamRoapMediaConnectionConstructorStub); + assert.calledWith( + multistreamRoapMediaConnectionConstructorStub, + { + iceServers: [], + }, + 'meeting id' + ); + }); + [ {testCase: 'turnServerInfo is undefined', turnServerInfo: undefined}, { diff --git a/packages/@webex/plugin-meetings/test/unit/spec/media/properties.ts b/packages/@webex/plugin-meetings/test/unit/spec/media/properties.ts index e4b8c3d94e5..7b6c3fe1911 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/media/properties.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/media/properties.ts @@ -2,6 +2,7 @@ import 'jsdom-global/register'; import {assert} from '@webex/test-helper-chai'; import sinon from 'sinon'; import {ConnectionState} from '@webex/internal-media-core'; +import * as tsSdpModule from '@webex/ts-sdp'; import MediaProperties from '@webex/plugin-meetings/src/media/properties'; import {Defer} from '@webex/common'; import MediaConnectionAwaiter from '../../../../src/media/MediaConnectionAwaiter'; @@ -10,15 +11,21 @@ describe('MediaProperties', () => { let mediaProperties; let mockMC; let clock; + let rtcPeerConnection; beforeEach(() => { clock = sinon.useFakeTimers(); + rtcPeerConnection = { + localDescription: {sdp: ''}, + }; + mockMC = { getStats: sinon.stub().resolves([]), on: sinon.stub(), off: sinon.stub(), getConnectionState: sinon.stub().returns(ConnectionState.Connected), + multistreamConnection: {pc: {pc: rtcPeerConnection}}, }; mediaProperties = new MediaProperties(); @@ -81,6 +88,129 @@ describe('MediaProperties', () => { assert.equal(numTransports, 0); }); + describe('ipVersion', () => { + it('returns ipVersion=undefined if getStats() returns no candidate pairs', async () => { + mockMC.getStats.resolves([{type: 'something', id: '1234'}]); + const info = await mediaProperties.getCurrentConnectionInfo(); + assert.equal(info.ipVersion, undefined); + }); + + it('returns ipVersion=undefined if getStats() returns no selected candidate pair', async () => { + mockMC.getStats.resolves([{type: 'candidate-pair', id: '1234', selected: false}]); + const info = await mediaProperties.getCurrentConnectionInfo(); + assert.equal(info.ipVersion, undefined); + }); + + it('returns ipVersion="IPv4" if transport has selectedCandidatePairId and local candidate has IPv4 address', async () => { + mockMC.getStats.resolves([ + {type: 'transport', id: 't1', selectedCandidatePairId: 'cp1'}, + {type: 'candidate-pair', id: 'cp1', localCandidateId: 'lc1'}, + {type: 'local-candidate', id: 'lc1', address: '192.168.1.1'}, + ]); + const info = await mediaProperties.getCurrentConnectionInfo(); + assert.equal(info.ipVersion, 'IPv4'); + }); + + it('returns ipVersion="IPv6" if transport has selectedCandidatePairId and local candidate has IPv6 address', async () => { + mockMC.getStats.resolves([ + {type: 'transport', id: 't1', selectedCandidatePairId: 'cp1'}, + {type: 'candidate-pair', id: 'cp1', localCandidateId: 'lc1'}, + {type: 'local-candidate', id: 'lc1', address: 'fd8f:12e6:5e53:784f:a0ba:f8d5:b906:1acc'}, + ]); + const info = await mediaProperties.getCurrentConnectionInfo(); + assert.equal(info.ipVersion, 'IPv6'); + }); + + it('returns ipVersion="IPv4" if transport has no selectedCandidatePairId but finds selected candidate pair and local candidate has IPv4 address', async () => { + mockMC.getStats.resolves([ + {type: 'transport', id: 't1'}, + {type: 'candidate-pair', id: 'cp2', localCandidateId: 'lc2', selected: true}, + {type: 'local-candidate', id: 'lc2', address: '10.0.0.1'}, + ]); + const info = await mediaProperties.getCurrentConnectionInfo(); + assert.equal(info.ipVersion, 'IPv4'); + }); + + it('returns ipVersion="IPv6" if transport has no selectedCandidatePairId but finds selected candidate pair and local candidate has IPv6 address', async () => { + mockMC.getStats.resolves([ + {type: 'transport', id: 't1'}, + {type: 'candidate-pair', id: 'cp2', localCandidateId: 'lc2', selected: true}, + {type: 'local-candidate', id: 'lc2', address: 'fe80::1ff:fe23:4567:890a'}, + ]); + const info = await mediaProperties.getCurrentConnectionInfo(); + assert.equal(info.ipVersion, 'IPv6'); + }); + + describe('local candidate without address', () => { + it('return="IPv4" if candidate from SDP with matching port number has IPv4 address', async () => { + sinon.stub(tsSdpModule, 'parse').returns({ + avMedia: [ + { + iceInfo: { + candidates: [ + { + port: 1234, + connectionAddress: '192.168.0.1', + }, + ], + }, + }, + ], + }); + + mockMC.getStats.resolves([ + {type: 'transport', id: 't1'}, + {type: 'candidate-pair', id: 'cp2', localCandidateId: 'lc2', selected: true}, + {type: 'local-candidate', id: 'lc2', port: 1234}, + ]); + const info = await mediaProperties.getCurrentConnectionInfo(); + assert.equal(info.ipVersion, 'IPv4'); + + assert.calledWith(tsSdpModule.parse, rtcPeerConnection.localDescription.sdp); + }); + + it('returns ipVersion="IPv6" if candidate from SDP with matching port number has IPv6 address', async () => { + sinon.stub(tsSdpModule, 'parse').returns({ + avMedia: [ + { + iceInfo: { + candidates: [ + { + port: 5000, + connectionAddress: 'fe80::1ff:fe23:4567:890a', + }, + ], + }, + }, + ], + }); + + mockMC.getStats.resolves([ + {type: 'transport', id: 't1'}, + {type: 'candidate-pair', id: 'cp2', localCandidateId: 'lc2', selected: true}, + {type: 'local-candidate', id: 'lc2', port: 5000}, + ]); + const info = await mediaProperties.getCurrentConnectionInfo(); + assert.equal(info.ipVersion, 'IPv6'); + + assert.calledWith(tsSdpModule.parse, rtcPeerConnection.localDescription.sdp); + }); + + it('returns ipVersion=undefined if parsing of the SDP fails', async () => { + sinon.stub(tsSdpModule, 'parse').throws(new Error('fake error')); + + mockMC.getStats.resolves([ + {type: 'candidate-pair', id: 'cp2', localCandidateId: 'lc2', selected: true}, + {type: 'local-candidate', id: 'lc2', port: 5000}, + ]); + const info = await mediaProperties.getCurrentConnectionInfo(); + assert.equal(info.ipVersion, undefined); + + assert.calledWith(tsSdpModule.parse, rtcPeerConnection.localDescription.sdp); + }); + }); + }); + describe('selectedCandidatePairChanges and numTransports', () => { it('returns correct values when getStats() returns no transport stats at all', async () => { mockMC.getStats.resolves([{type: 'something', id: '1234'}]); 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 471cdd8e7f9..7864a260024 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 @@ -76,6 +76,8 @@ describe('plugin-meetings', () => { canShareDesktop: null, canShareContent: null, canTransferFile: null, + canRealtimeCloseCaption: null, + canRealtimeCloseCaptionManual: null, canChat: null, canDoVideo: null, canAnnotate: null, @@ -99,6 +101,7 @@ describe('plugin-meetings', () => { canDisableAnnotation: null, canEnableRemoteDesktopControl: null, canDisableRemoteDesktopControl: null, + canMoveToLobby: null, ...expected, }; @@ -181,6 +184,8 @@ describe('plugin-meetings', () => { 'canShareDesktop', 'canShareContent', 'canTransferFile', + 'canRealtimeCloseCaption', + 'canRealtimeCloseCaptionManual', 'canChat', 'canDoVideo', 'canAnnotate', @@ -204,6 +209,7 @@ describe('plugin-meetings', () => { 'canDisableAnnotation', 'canEnableRemoteDesktopControl', 'canDisableRemoteDesktopControl', + 'canMoveToLobby', ].forEach((key) => { it(`get and set for ${key} work as expected`, () => { const inMeetingActions = new InMeetingActions(); 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 80c8eae568b..7905a3bdb07 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js @@ -243,6 +243,7 @@ describe('plugin-meetings', () => { }, }); + webex.internal.newMetrics.callDiagnosticMetrics.clearErrorCache = sinon.stub(); webex.internal.support.submitLogs = sinon.stub().returns(Promise.resolve()); webex.internal.services = {get: sinon.stub().returns('locus-url')}; webex.credentials.getOrgId = sinon.stub().returns('fake-org-id'); @@ -253,6 +254,7 @@ describe('plugin-meetings', () => { getReachabilityResults: sinon.stub().resolves(undefined), getReachabilityMetrics: sinon.stub().resolves({}), stopReachability: sinon.stub(), + isSubnetReachable: sinon.stub().returns(true), }; webex.internal.llm.on = sinon.stub(); webex.internal.newMetrics.callDiagnosticLatencies = new CallDiagnosticLatencies( @@ -1809,7 +1811,6 @@ describe('plugin-meetings', () => { await meeting.join(); joinSucceeded = true; } catch (e) { - assert.isTrue(e.handledBySdk) assert.instanceOf(e, IntentToJoinError); } assert.isFalse(joinSucceeded); @@ -1862,20 +1863,7 @@ describe('plugin-meetings', () => { }); }); it('should try to join the meeting and return promise reject', async () => { - await meeting.join().catch((e) => { - assert.isTrue(e.handledBySdk); - assert.calledOnce(MeetingUtil.joinMeeting); - }); - }); - - it('should try to join the meeting and return deferred promise reject', async () => { - - // call first - meeting.join(); - - // call 2nd time will get the deferred promise - await meeting.join().catch((e) => { - assert.isTrue(e.handledBySdk); + await meeting.join().catch(() => { assert.calledOnce(MeetingUtil.joinMeeting); }); }); @@ -2059,7 +2047,12 @@ describe('plugin-meetings', () => { meeting.mediaProperties.waitForMediaConnectionConnected = sinon.stub().resolves(); meeting.mediaProperties.getCurrentConnectionInfo = sinon .stub() - .resolves({connectionType: 'udp', selectedCandidatePairChanges: 2, numTransports: 1}); + .resolves({ + connectionType: 'udp', + selectedCandidatePairChanges: 2, + numTransports: 1, + ipVersion: 'IPv6', + }); meeting.audio = muteStateStub; meeting.video = muteStateStub; sinon.stub(Media, 'createMediaConnection').returns(fakeMediaConnection); @@ -2122,6 +2115,7 @@ describe('plugin-meetings', () => { someReachabilityMetric2: 'some value2', }), stopReachability: sinon.stub(), + isSubnetReachable: sinon.stub().returns(false), }; const forceRtcMetricsSend = sinon.stub().resolves(); @@ -2177,6 +2171,7 @@ describe('plugin-meetings', () => { someReachabilityMetric1: 'some value1', someReachabilityMetric2: 'some value2', selectedCandidatePairChanges: 2, + isSubnetReachable: null, numTransports: 1, iceCandidatesCount: 0, } @@ -2223,6 +2218,7 @@ describe('plugin-meetings', () => { signalingState: 'unknown', connectionState: 'unknown', iceConnectionState: 'unknown', + isSubnetReachable: null, }) ); @@ -2237,6 +2233,7 @@ describe('plugin-meetings', () => { someReachabilityMetric1: 'some value1', someReachabilityMetric2: 'some value2', }), + isSubnetReachable: sinon.stub().returns(true), }; meeting.waitForRemoteSDPAnswer = sinon.stub().rejects(); @@ -2287,6 +2284,7 @@ describe('plugin-meetings', () => { selectedCandidatePairChanges: 2, numTransports: 1, iceCandidatesCount: 0, + isSubnetReachable: null, } ); }); @@ -2344,6 +2342,7 @@ describe('plugin-meetings', () => { signalingState: 'have-local-offer', connectionState: 'connecting', iceConnectionState: 'checking', + isSubnetReachable: null, }) ); @@ -2401,6 +2400,7 @@ describe('plugin-meetings', () => { signalingState: 'have-local-offer', connectionState: 'connecting', iceConnectionState: 'checking', + isSubnetReachable: null, }) ); @@ -2736,8 +2736,9 @@ describe('plugin-meetings', () => { sinon.stub().returns(FAKE_ERROR)); webex.meetings.reachability = { isWebexMediaBackendUnreachable: sinon.stub().resolves(false), - getReachabilityMetrics: sinon.stub().resolves(), + getReachabilityMetrics: sinon.stub().resolves({}), stopReachability: sinon.stub(), + isSubnetReachable: sinon.stub().returns(true), }; const MOCK_CLIENT_ERROR_CODE = 2004; const generateClientErrorCodeForIceFailureStub = sinon @@ -2766,7 +2767,8 @@ describe('plugin-meetings', () => { turnDiscoverySkippedReason: undefined, }); meeting.meetingState = 'ACTIVE'; - meeting.mediaProperties.waitForMediaConnectionConnected.rejects({iceConnected: false}); + const error = {iceConnected: false}; + meeting.mediaProperties.waitForMediaConnectionConnected.rejects(error); const forceRtcMetricsSend = sinon.stub().resolves(); const closeMediaConnectionStub = sinon.stub(); @@ -2784,6 +2786,7 @@ describe('plugin-meetings', () => { }) .catch((err) => { errorThrown = err; + assert.instanceOf(err.cause, Error); assert.instanceOf(err, AddMediaFailed); }); @@ -2840,6 +2843,7 @@ describe('plugin-meetings', () => { }, options: { meetingId: meeting.id, + rawError: error, }, }); assert.calledWith(webex.internal.newMetrics.submitClientEvent.thirdCall, { @@ -2851,6 +2855,7 @@ describe('plugin-meetings', () => { }, options: { meetingId: meeting.id, + rawError: error, }, }); @@ -2917,6 +2922,7 @@ describe('plugin-meetings', () => { selectedCandidatePairChanges: 2, numTransports: 1, iceCandidatesCount: 0, + isSubnetReachable: null, }, ]); @@ -2947,6 +2953,7 @@ describe('plugin-meetings', () => { .resolves(false), getReachabilityMetrics: sinon.stub().resolves({}), stopReachability: sinon.stub(), + isSubnetReachable: sinon.stub().returns(true), }; const getErrorPayloadForClientErrorCodeStub = (webex.internal.newMetrics.callDiagnosticMetrics.getErrorPayloadForClientErrorCode = @@ -2977,10 +2984,13 @@ describe('plugin-meetings', () => { }, turnDiscoverySkippedReason: undefined, }); + + const mediaConnectionError = new Error('fake error'); + meeting.mediaProperties.waitForMediaConnectionConnected = sinon .stub() .onFirstCall() - .rejects() + .rejects(mediaConnectionError) .onSecondCall() .resolves(); @@ -3049,10 +3059,14 @@ describe('plugin-meetings', () => { }, options: { meetingId: meeting.id, + rawError: mediaConnectionError, }, }); assert.calledWith(webex.internal.newMetrics.submitClientEvent.thirdCall, { name: 'client.media-engine.ready', + payload: { + ipVersion: 'IPv6', + }, options: { meetingId: meeting.id, }, @@ -3109,11 +3123,13 @@ describe('plugin-meetings', () => { locus_id: meeting.locusUrl.split('/').pop(), connectionType: 'udp', selectedCandidatePairChanges: 2, + ipVersion: 'IPv6', numTransports: 1, isMultistream: false, retriedWithTurnServer: true, isJoinWithMediaRetry: false, iceCandidatesCount: 0, + isSubnetReachable: null, }, ]); meeting.roap.doTurnDiscovery; @@ -3242,6 +3258,7 @@ describe('plugin-meetings', () => { someReachabilityMetric2: 'some value2', }), stopReachability: sinon.stub(), + isSubnetReachable: sinon.stub().returns(true), }; meeting.iceCandidatesCount = 3; meeting.iceCandidateErrors.set('701_error', 3); @@ -3260,6 +3277,7 @@ describe('plugin-meetings', () => { locus_id: meeting.locusUrl.split('/').pop(), connectionType: 'udp', selectedCandidatePairChanges: 2, + ipVersion: 'IPv6', numTransports: 1, isMultistream: false, retriedWithTurnServer: false, @@ -3269,6 +3287,7 @@ describe('plugin-meetings', () => { iceCandidatesCount: 3, '701_error': 3, '701_turn_host_lookup_received_error': 1, + isSubnetReachable: null, } ); @@ -3331,6 +3350,7 @@ describe('plugin-meetings', () => { iceConnectionState: 'unknown', selectedCandidatePairChanges: 2, numTransports: 1, + isSubnetReachable: null, iceCandidatesCount: 0, } ); @@ -3392,6 +3412,117 @@ describe('plugin-meetings', () => { numTransports: 1, '701_error': 2, '701_turn_host_lookup_received_error': 1, + isSubnetReachable: null, + iceCandidatesCount: 0, + } + ); + + assert.isOk(errorThrown); + }); + + it('should send valid isSubnetReachability if media connection success', async () => { + meeting.roap.doTurnDiscovery = sinon.stub().returns({ + turnServerInfo: undefined, + turnDiscoverySkippedReason: undefined, + }); + meeting.meetingState = 'ACTIVE'; + meeting.mediaProperties.waitForMediaConnectionConnected.resolves(); + meeting.webex.meetings.reachability = { + getReachabilityMetrics: sinon.stub().resolves({ + reachability_public_udp_success: 5, + }), + stopReachability: sinon.stub(), + isSubnetReachable: sinon.stub().returns(false), + }; + + const forceRtcMetricsSend = sinon.stub().resolves(); + const closeMediaConnectionStub = sinon.stub(); + Media.createMediaConnection = sinon.stub().returns({ + close: closeMediaConnectionStub, + forceRtcMetricsSend, + getConnectionState: sinon.stub().returns(ConnectionState.Connected), + initiateOffer: sinon.stub().resolves({}), + on: sinon.stub(), + }); + + await meeting.addMedia({ + mediaSettings: {}, + }); + + assert.calledWith(Metrics.sendBehavioralMetric, BEHAVIORAL_METRICS.ADD_MEDIA_SUCCESS, { + correlation_id: meeting.correlationId, + locus_id: meeting.locusUrl.split('/').pop(), + connectionType: 'udp', + ipVersion: 'IPv6', + selectedCandidatePairChanges: 2, + numTransports: 1, + isMultistream: false, + retriedWithTurnServer: false, + isJoinWithMediaRetry: false, + iceCandidatesCount: 0, + reachability_public_udp_success: 5, + isSubnetReachable: false, + }); + }); + + it('should send valid isSubnetReachability if media connection fails', async () => { + let errorThrown = undefined; + + meeting.roap.doTurnDiscovery = sinon.stub().returns({ + turnServerInfo: undefined, + turnDiscoverySkippedReason: undefined, + }); + meeting.meetingState = 'ACTIVE'; + meeting.mediaProperties.waitForMediaConnectionConnected.rejects({iceConnected: false}); + meeting.webex.meetings.reachability = { + getReachabilityMetrics: sinon.stub().resolves({ + reachability_public_udp_success: 5, + }), + stopReachability: sinon.stub(), + isSubnetReachable: sinon.stub().returns(true), + }; + + const forceRtcMetricsSend = sinon.stub().resolves(); + const closeMediaConnectionStub = sinon.stub(); + Media.createMediaConnection = sinon.stub().returns({ + close: closeMediaConnectionStub, + forceRtcMetricsSend, + getConnectionState: sinon.stub().returns(ConnectionState.Connected), + initiateOffer: sinon.stub().resolves({}), + on: sinon.stub(), + }); + + await meeting + .addMedia({ + mediaSettings: {}, + }) + .catch((err) => { + errorThrown = err; + assert.instanceOf(err, AddMediaFailed); + }); + + // Check that the only metric sent is ADD_MEDIA_FAILURE + assert.calledOnceWithExactly( + Metrics.sendBehavioralMetric, + BEHAVIORAL_METRICS.ADD_MEDIA_FAILURE, + { + correlation_id: meeting.correlationId, + locus_id: meeting.locusUrl.split('/').pop(), + reason: errorThrown.message, + stack: errorThrown.stack, + code: errorThrown.code, + turnDiscoverySkippedReason: undefined, + turnServerUsed: true, + retriedWithTurnServer: false, + isMultistream: false, + isJoinWithMediaRetry: false, + signalingState: 'unknown', + connectionState: 'unknown', + iceConnectionState: 'unknown', + selectedCandidatePairChanges: 2, + numTransports: 1, + reachability_public_udp_success: 5, + isSubnetReachable: true, iceCandidatesCount: 0, } ); @@ -3411,6 +3542,8 @@ describe('plugin-meetings', () => { meeting.config.stats.enableStatsAnalyzer = true; statsAnalyzerStub = new EventsScope(); + statsAnalyzerStub.getNetworkType = sinon.stub().returns('wifi'); + // mock the StatsAnalyzer constructor sinon.stub(InternalMediaCoreModule, 'StatsAnalyzer').returns(statsAnalyzerStub); @@ -3705,7 +3838,7 @@ describe('plugin-meetings', () => { }, }; sinon.stub(meeting, 'getMembers').returns({membersCollection: fakeMembersCollection}); - const fakeData = {intervalMetadata: {}, networkType: 'wifi'}; + const fakeData = {intervalMetadata: {}}; statsAnalyzerStub.emit( {file: 'test', function: 'test'}, @@ -3746,7 +3879,7 @@ describe('plugin-meetings', () => { }); it('calls submitMQE correctly', async () => { - const fakeData = {intervalMetadata: {bla: 'bla'}, networkType: 'wifi'}; + const fakeData = {intervalMetadata: {bla: 'bla'}}; statsAnalyzerStub.emit( {file: 'test', function: 'test'}, @@ -3954,6 +4087,9 @@ describe('plugin-meetings', () => { }, options: { meetingId: meeting.id, + rawError: { + iceConnected: false, + }, }, }, ]); @@ -10741,6 +10877,7 @@ describe('plugin-meetings', () => { let canUserRenameSelfAndObservedSpy; let canUserRenameOthersSpy; let canShareWhiteBoardSpy; + let canMoveToLobbySpy; // Due to import tree issues, hasHints must be stubed within the scope of the `it`. beforeEach(() => { @@ -10771,6 +10908,7 @@ describe('plugin-meetings', () => { ); canUserRenameOthersSpy = sinon.spy(MeetingUtil, 'canUserRenameOthers'); canShareWhiteBoardSpy = sinon.spy(MeetingUtil, 'canShareWhiteBoard'); + canMoveToLobbySpy = sinon.spy(MeetingUtil, 'canMoveToLobby'); }); afterEach(() => { @@ -10868,6 +11006,16 @@ describe('plugin-meetings', () => { requiredDisplayHints: [], requiredPolicies: [SELF_POLICY.SUPPORT_FILE_TRANSFER], }, + { + actionName: 'canRealtimeCloseCaption', + requiredDisplayHints: [], + requiredPolicies: [SELF_POLICY.SUPPORT_REALTIME_CLOSE_CAPTION], + }, + { + actionName: 'canRealtimeCloseCaptionManual', + requiredDisplayHints: [], + requiredPolicies: [SELF_POLICY.SUPPORT_REALTIME_CLOSE_CAPTION_MANUAL], + }, { actionName: 'canChat', requiredDisplayHints: [], @@ -11312,6 +11460,7 @@ describe('plugin-meetings', () => { assert.calledWith(requiresPostMeetingDataConsentPromptSpy, userDisplayHints); assert.calledWith(canUserRenameOthersSpy, userDisplayHints); assert.calledWith(canShareWhiteBoardSpy, userDisplayHints, selfUserPolicies); + assert.calledWith(canMoveToLobbySpy, userDisplayHints); assert.calledWith(ControlsOptionsUtil.hasHints, { requiredHints: [DISPLAY_HINTS.MUTE_ALL], 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 8a83e786197..eef4001121e 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/utils.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/utils.js @@ -789,10 +789,14 @@ describe('plugin-meetings', () => { }), false ); - assert.deepEqual( - MeetingUtil.canShareWhiteBoard(['SHARE_WHITEBOARD'], undefined), - false - ); + assert.deepEqual(MeetingUtil.canShareWhiteBoard(['SHARE_WHITEBOARD'], undefined), false); + }); + }); + + describe('canMoveToLobby', () => { + it('works as expected', () => { + assert.deepEqual(MeetingUtil.canMoveToLobby(['MOVE_TO_LOBBY']), true); + assert.deepEqual(MeetingUtil.canMoveToLobby([]), false); }); }); 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 171046440b6..19e9ddfca90 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meetings/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meetings/index.js @@ -182,6 +182,15 @@ describe('plugin-meetings', () => { metrics: { submitClientMetrics: sinon.stub().returns(Promise.resolve()), }, + newMetrics: { + submitClientEvent: sinon.stub(), + callDiagnosticLatencies: { + measureLatency: sinon.stub().returns(Promise.resolve()), + }, + callDiagnosticMetrics: { + clearErrorCache: sinon.stub(), + }, + }, }); webex.emit('ready'); }); @@ -391,6 +400,19 @@ describe('plugin-meetings', () => { }); }); + describe('#_toggleDisableAudioMainDtx', () => { + it('should have _toggleDisableAudioMainDtx', () => { + assert.equal(typeof webex.meetings._toggleDisableAudioMainDtx, 'function'); + }); + + describe('success', () => { + it('should update meetings to disable audio main dtx', () => { + webex.meetings._toggleDisableAudioMainDtx(true); + assert.equal(webex.meetings.config.experimental.disableAudioMainDtx, true); + }); + }); + }); + describe('Public API Contracts', () => { describe('#register', () => { it('emits an event and resolves when register succeeds', async () => { @@ -583,6 +605,24 @@ describe('plugin-meetings', () => { await assert.isRejected(webex.meetings.unregister()); }); + it('does not reject when device.unregister fails with statusCode 404', (done) => { + webex.meetings.registered = true; + webex.internal.device.unregister = sinon.stub().rejects({statusCode: 404}); + webex.meetings.unregister().then(() => { + assert.calledWith( + TriggerProxy.trigger, + sinon.match.instanceOf(Meetings), + { + file: 'meetings', + function: 'unregister', + }, + 'meetings:unregistered' + ); + assert.isFalse(webex.meetings.registered); + done(); + }); + }); + it('rejects when mercury.disconnect fails', async () => { webex.meetings.registered = true; webex.internal.mercury.disconnect = sinon.stub().returns(Promise.reject()); @@ -631,6 +671,7 @@ describe('plugin-meetings', () => { quality: 'LOW', authToken: 'fake_token', mirror: false, + canvasResolutionScaling: 1, }); assert.exists(result.enable); assert.exists(result.disable); @@ -646,6 +687,7 @@ describe('plugin-meetings', () => { quality: 'HIGH', blurStrength: 'STRONG', bgImageUrl: 'https://test.webex.com/landscape.5a535788.jpg', + canvasResolutionScaling: 1, }; const result = await webex.meetings.createVirtualBackgroundEffect(effectOptions); @@ -680,7 +722,6 @@ describe('plugin-meetings', () => { audioContext: {}, authToken: 'fake_token', mode: 'WORKLET', - env: 'prod', avoidSimd: false, }); assert.exists(result.enable); diff --git a/packages/@webex/plugin-meetings/test/unit/spec/members/index.js b/packages/@webex/plugin-meetings/test/unit/spec/members/index.js index 23627ab151b..1695eda99c2 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/members/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/members/index.js @@ -120,9 +120,9 @@ describe('plugin-meetings', () => { meeting = { request: sinon.mock().returns(Promise.resolve()), locusInfo: { - sequence: {} - } - } + sequence: {}, + }, + }; createMembers = (options) => new Members({locusUrl: options.url, meeting}, {parent: webex}); }); @@ -349,7 +349,7 @@ describe('plugin-meetings', () => { {type: 'COHOST', hasRole: true}, ]; - const resolvedValue = "it worked"; + const resolvedValue = 'it worked'; const genericMessage = 'Generic error from the API'; @@ -364,9 +364,13 @@ describe('plugin-meetings', () => { }; if (errorCode) { - spies.assignRolesMember = sandbox.stub(members.membersRequest, 'assignRolesMember').rejects({body: {errorCode}, message: genericMessage}); + spies.assignRolesMember = sandbox + .stub(members.membersRequest, 'assignRolesMember') + .rejects({body: {errorCode}, message: genericMessage}); } else { - spies.assignRolesMember = sandbox.stub(members.membersRequest, 'assignRolesMember').resolves(resolvedValue); + spies.assignRolesMember = sandbox + .stub(members.membersRequest, 'assignRolesMember') + .resolves(resolvedValue); } return {members, spies}; @@ -378,7 +382,15 @@ describe('plugin-meetings', () => { assert.notCalled(spies.assignRolesMember); }; - const checkError = async (error, expectedMemberId, expectedRoles, expectedLocusUrl, resultPromise, expectedMessage, spies) => { + const checkError = async ( + error, + expectedMemberId, + expectedRoles, + expectedLocusUrl, + resultPromise, + expectedMessage, + spies + ) => { await assert.isRejected(resultPromise, error, expectedMessage); assert.calledOnceWithExactly( spies.generateRoleAssignmentMemberOptions, @@ -423,7 +435,7 @@ describe('plugin-meetings', () => { await checkInvalid( resultPromise, 'The member id must be defined to assign the roles to a member.', - spies, + spies ); }); @@ -435,7 +447,7 @@ describe('plugin-meetings', () => { await checkInvalid( resultPromise, 'The associated locus url for this meetings members object must be defined.', - spies, + spies ); }); @@ -452,7 +464,7 @@ describe('plugin-meetings', () => { url1, resultPromise, 'Non converged meetings, PSTN or SIP users in converged meetings are not supported currently.', - spies, + spies ); }); @@ -469,7 +481,7 @@ describe('plugin-meetings', () => { url1, resultPromise, 'Reclaim Host Role Not Allowed For Other Participants. Participants cannot claim host role in PMR meeting, space instant meeting or escalated instant meeting. However, the original host still can reclaim host role when it manually makes another participant to be the host.', - spies, + spies ); }); @@ -486,7 +498,7 @@ describe('plugin-meetings', () => { url1, resultPromise, 'Host Key Not Specified Or Matched. The original host can reclaim the host role without entering the host key. However, any other person who claims the host role must enter the host key to get it.', - spies, + spies ); }); @@ -503,7 +515,7 @@ describe('plugin-meetings', () => { url1, resultPromise, 'Participant Having Host Role Already. Participant who sends request to reclaim host role has already a host role.', - spies, + spies ); }); @@ -520,7 +532,7 @@ describe('plugin-meetings', () => { url1, resultPromise, genericMessage, - spies, + spies ); }); @@ -530,13 +542,7 @@ describe('plugin-meetings', () => { const resultPromise = members.assignRoles(memberId, fakeRoles); - await checkValid( - resultPromise, - spies, - memberId, - fakeRoles, - url1, - ); + await checkValid(resultPromise, spies, memberId, fakeRoles, url1); }); }); @@ -661,19 +667,19 @@ describe('plugin-meetings', () => { spies, expectedRequestingMemberId, expectedLocusUrl, - expectedRoles, + expectedRoles ) => { await assert.isFulfilled(resultPromise); assert.calledOnceWithExactly( spies.generateLowerAllHandsMemberOptions, expectedRequestingMemberId, expectedLocusUrl, - expectedRoles, + expectedRoles ); assert.calledOnceWithExactly(spies.lowerAllHandsMember, { requestingParticipantId: expectedRequestingMemberId, locusUrl: expectedLocusUrl, - ...(expectedRoles !== undefined && { roles: expectedRoles }) + ...(expectedRoles !== undefined && {roles: expectedRoles}), }); assert.strictEqual(resultPromise, spies.lowerAllHandsMember.getCall(0).returnValue); }; @@ -714,7 +720,7 @@ describe('plugin-meetings', () => { it('should make the correct request when called with valid requestingMemberId and roles', async () => { const requestingMemberId = 'test-member-id'; const roles = ['panelist', 'attendee']; - const { members, spies } = setup('test-locus-url'); + const {members, spies} = setup('test-locus-url'); const resultPromise = members.lowerAllHands(requestingMemberId, roles); @@ -724,7 +730,7 @@ describe('plugin-meetings', () => { it('should handle an empty roles array correctly', async () => { const requestingMemberId = 'test-member-id'; const roles = []; - const { members, spies } = setup('test-locus-url'); + const {members, spies} = setup('test-locus-url'); const resultPromise = members.lowerAllHands(requestingMemberId, roles); @@ -977,5 +983,76 @@ describe('plugin-meetings', () => { ); }); }); + + describe('#moveToLobby', () => { + const setup = (locusUrl) => { + const members = createMembers({url: locusUrl}); + + const spies = { + getMoveMemberToLobbyRequestBody: sandbox.spy( + MembersUtil, + 'getMoveMemberToLobbyRequestBody' + ), + moveToLobbyMember: sandbox.spy(members.membersRequest, 'moveToLobbyMember'), + }; + + return {members, spies}; + }; + + const checkInvalid = async (resultPromise, expectedMessage, spies) => { + await assert.isRejected(resultPromise, ParameterError, expectedMessage); + assert.notCalled(spies.getMoveMemberToLobbyRequestBody); + assert.notCalled(spies.moveToLobbyMember); + }; + + const checkValid = async (resultPromise, spies, expectedMemberId, expectedLocusUrl) => { + await assert.isFulfilled(resultPromise); + assert.calledOnceWithExactly(spies.getMoveMemberToLobbyRequestBody, expectedMemberId); + assert.calledOnceWithExactly( + spies.moveToLobbyMember, + { + locusUrl: expectedLocusUrl, + memberId: expectedMemberId, + }, + { + moveToLobby: {participantIds: [expectedMemberId]}, + } + ); + assert.strictEqual(resultPromise, spies.moveToLobbyMember.getCall(0).returnValue); + }; + + it('should not make a request if there is no member id', async () => { + const {members, spies} = setup(url1); + + const resultPromise = members.moveToLobby(); + + await checkInvalid( + resultPromise, + 'The member id must be defined to move the member to lobby.', + spies + ); + }); + + it('should not make a request if there is no locus url', async () => { + const {members, spies} = setup(); + + const resultPromise = members.moveToLobby(uuid.v4()); + + await checkInvalid( + resultPromise, + 'The associated locus url for this meetings members object must be defined.', + spies + ); + }); + + it('should make the correct request when called with valid memberId and locusUrl', async () => { + const memberId = uuid.v4(); + const {members, spies} = setup(url1); + + const resultPromise = members.moveToLobby(memberId); + + await checkValid(resultPromise, spies, memberId, url1); + }); + }); }); }); diff --git a/packages/@webex/plugin-meetings/test/unit/spec/members/request.js b/packages/@webex/plugin-meetings/test/unit/spec/members/request.js index c743c2fc6a9..668e81f84cd 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/members/request.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/members/request.js @@ -9,7 +9,7 @@ import Meetings from '@webex/plugin-meetings'; import MembersRequest from '@webex/plugin-meetings/src/members/request'; import membersUtil from '@webex/plugin-meetings/src/members/util'; import ParameterError from '@webex/plugin-meetings/src/common/errors/parameter'; -import { merge } from 'lodash'; +import {merge} from 'lodash'; const {assert} = chai; @@ -65,10 +65,7 @@ describe('plugin-meetings', () => { const checkRequest = (expectedParams) => { assert.calledOnceWithExactly(locusDeltaRequestSpy, expectedParams); - assert.calledOnceWithExactly( - membersRequest.request, - merge(expectedParams, {body: {sequence}}) - ); + assert.calledOnceWithExactly(membersRequest.request, merge(expectedParams, {body: {sequence}})); }; describe('members request library', () => { @@ -98,8 +95,8 @@ describe('plugin-meetings', () => { }, device: { url, - } - } + }, + }, }); }); }); @@ -120,9 +117,9 @@ describe('plugin-meetings', () => { uri: url1, body: { alertIfActive: undefined, - invitees: [{address: '+18578675309'}] - } - }) + invitees: [{address: '+18578675309'}], + }, + }); }); }); @@ -133,16 +130,16 @@ describe('plugin-meetings', () => { memberIds: ['1', '2'], }; - await membersRequest.admitMember(options) + await membersRequest.admitMember(options); checkRequest({ method: 'PUT', uri: 'https://example.com/12345/controls', body: { admit: { - participantIds: options.memberIds - } - } + participantIds: options.memberIds, + }, + }, }); }); }); @@ -160,7 +157,7 @@ describe('plugin-meetings', () => { method: 'PUT', uri: 'https://example.com/12345/participant/member1/leave', body: { - reason: undefined + reason: undefined, }, }); }); @@ -247,9 +244,9 @@ describe('plugin-meetings', () => { uri: `${locusUrl}/participant/${memberId}/controls`, body: { role: { - roles - } - } + roles, + }, + }, }); }); }); @@ -272,9 +269,9 @@ describe('plugin-meetings', () => { uri: `${locusUrl}/participant/${memberId}/controls`, body: { hand: { - raised: true - } - } + raised: true, + }, + }, }); }); }); @@ -406,7 +403,33 @@ describe('plugin-meetings', () => { body: { aliasValue, requestingParticipantId, - } + }, + }); + }); + }); + + describe('#moveToLobby', () => { + it('sends a moveToLobbyMember PATCH to the locus endpoint', async () => { + const locusUrl = url1; + const memberId = 'test1'; + const options = { + locusUrl: locusUrl, + memberId, + }; + const body = { + moveToLobby: {participantIds: [memberId]}, + }; + + const getRequestParamsSpy = sandbox.spy(membersUtil, 'getMoveMemberToLobbyRequestParams'); + + await membersRequest.moveToLobbyMember(options, body); + + assert.calledOnceWithExactly(getRequestParamsSpy, options, body); + + checkRequest({ + method: 'PATCH', + uri: `${locusUrl}/participant/${memberId}/controls`, + body: {moveToLobby: {participantIds: [memberId]}}, }); }); }); diff --git a/packages/@webex/plugin-meetings/test/unit/spec/members/utils.js b/packages/@webex/plugin-meetings/test/unit/spec/members/utils.js index e79aca4e490..f1d4a36f9da 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/members/utils.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/members/utils.js @@ -357,5 +357,38 @@ describe('plugin-meetings', () => { }); }); }); + + describe('#getMoveMemberToLobbyRequestBody', () => { + it('returns the correct options', () => { + const memberId = 'test1'; + assert.deepEqual(MembersUtil.getMoveMemberToLobbyRequestBody(memberId), { + moveToLobby: { + participantIds: [memberId], + }, + }); + }); + }); + + describe('#getMoveMemberToLobbyRequestParams', () => { + it('returns the correct params', () => { + const locusUrl = 'TestLocusUrl'; + const memberId = 'test1'; + const options = { + locusUrl: locusUrl, + memberId, + }; + const body = { + moveToLobby: {participantIds: [memberId]}, + }; + + const uri = `${options.locusUrl}/${PARTICIPANT}/${options.memberId}/${CONTROLS}`; + + assert.deepEqual(MembersUtil.getMoveMemberToLobbyRequestParams(options, body), { + method: HTTP_VERBS.PATCH, + uri, + body, + }); + }); + }); }); }); diff --git a/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts b/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts index 5ef732e2a99..6fdb0066f60 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts @@ -174,59 +174,6 @@ describe('ClusterReachability', () => { assert.deepEqual(emittedEvents[Events.clientMediaIpsUpdated], []); }); - it('resolves and has correct result as soon as it finds that all udp, tcp and tls are reachable', async () => { - const promise = clusterReachability.start(); - - await clock.tickAsync(100); - fakePeerConnection.onicecandidate({candidate: {type: 'srflx', address: 'somePublicIp'}}); - - // check the right events were emitted - assert.equal(emittedEvents[Events.resultReady].length, 1); - assert.deepEqual(emittedEvents[Events.resultReady][0], { - protocol: 'udp', - result: 'reachable', - latencyInMilliseconds: 100, - clientMediaIPs: ['somePublicIp'], - }); - - // clientMediaIpsUpdated shouldn't be emitted, because the IP is already passed in the resultReady event - assert.equal(emittedEvents[Events.clientMediaIpsUpdated].length, 0); - - await clock.tickAsync(100); - fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp'}}); - - // check the right event was emitted - assert.equal(emittedEvents[Events.resultReady].length, 2); - assert.deepEqual(emittedEvents[Events.resultReady][1], { - protocol: 'tcp', - result: 'reachable', - latencyInMilliseconds: 200, - }); - assert.equal(emittedEvents[Events.clientMediaIpsUpdated].length, 0); - - await clock.tickAsync(100); - fakePeerConnection.onicecandidate({ - candidate: {type: 'relay', address: 'someTurnRelayIp', port: 443}, - }); - - // check the right event was emitted - assert.equal(emittedEvents[Events.resultReady].length, 3); - assert.deepEqual(emittedEvents[Events.resultReady][2], { - protocol: 'xtls', - result: 'reachable', - latencyInMilliseconds: 300, - }); - assert.equal(emittedEvents[Events.clientMediaIpsUpdated].length, 0); - - await promise; - - assert.deepEqual(clusterReachability.getResult(), { - udp: {result: 'reachable', latencyInMilliseconds: 100, clientMediaIPs: ['somePublicIp']}, - tcp: {result: 'reachable', latencyInMilliseconds: 200}, - xtls: {result: 'reachable', latencyInMilliseconds: 300}, - }); - }); - it('resolves and returns correct results when aborted before it gets any candidates', async () => { const promise = clusterReachability.start(); @@ -275,7 +222,7 @@ describe('ClusterReachability', () => { await testUtils.flushPromises(); - fakePeerConnection.iceConnectionState = 'complete'; + fakePeerConnection.iceGatheringState = 'complete'; fakePeerConnection.onicegatheringstatechange(); await promise; @@ -293,7 +240,7 @@ describe('ClusterReachability', () => { await clock.tickAsync(30); fakePeerConnection.onicecandidate({candidate: {type: 'srflx', address: 'somePublicIp1'}}); - fakePeerConnection.iceConnectionState = 'complete'; + fakePeerConnection.iceGatheringState = 'complete'; fakePeerConnection.onicegatheringstatechange(); await promise; @@ -436,6 +383,9 @@ describe('ClusterReachability', () => { candidate: {type: 'relay', address: 'someTurnRelayIp', port: 443}, }); + fakePeerConnection.iceGatheringState = 'complete'; + fakePeerConnection.onicegatheringstatechange(); + await promise; assert.deepEqual(clusterReachability.getResult(), { @@ -474,6 +424,10 @@ describe('ClusterReachability', () => { candidate: {type: 'relay', address: 'someTurnRelayIp', port: 443}, }); + fakePeerConnection.iceGatheringState = 'complete'; + fakePeerConnection.onicegatheringstatechange(); + await clock.tickAsync(10); + await promise; assert.deepEqual(clusterReachability.getResult(), { @@ -486,5 +440,37 @@ describe('ClusterReachability', () => { xtls: {result: 'reachable', latencyInMilliseconds: 20}, }); }); + + it('should gather correctly reached subnets', async () => { + const promise = clusterReachability.start(); + + await clock.tickAsync(10); + fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:1.2.3.4:5004'}}); + fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:4.3.2.1:5004'}}); + fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp'}}); + + clusterReachability.abort(); + await promise; + + assert.deepEqual(Array.from(clusterReachability.reachedSubnets), [ + '1.2.3.4', + '4.3.2.1', + 'someTurnRelayIp' + ]); + }); + + it('should store only unique subnet address', async () => { + const promise = clusterReachability.start(); + + await clock.tickAsync(10); + fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:1.2.3.4:5004'}}); + fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:1.2.3.4:9000'}}); + fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: '1.2.3.4'}}); + + clusterReachability.abort(); + await promise; + + assert.deepEqual(Array.from(clusterReachability.reachedSubnets), ['1.2.3.4']); + }); }); }); diff --git a/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts b/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts index 89eff269843..0e75c8f2670 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts @@ -1955,6 +1955,7 @@ describe('gatherReachability', () => { receivedEvents[event] = receivedEvents[event] + 1 || 1; }); }; + it('works as expected', async () => { setListener('reachability:stopped'); setListener('reachability:done'); @@ -2016,6 +2017,59 @@ describe('gatherReachability', () => { assert.equal(receivedEvents['reachability:done'], undefined); assert.equal(receivedEvents['reachability:firstResultAvailable'], undefined); }); + + it('does not fallback when no clusters were reached and min clusters were specified', async () => { + setListener('reachability:stopped'); + setListener('reachability:done'); + setListener('reachability:firstResultAvailable'); + + const mockGetClustersResult = { + discoveryOptions: { + ['early-call-min-clusters']: 1, + }, + clusters: { + clusterA: { + udp: [], + tcp: [], + xtls: [], + isVideoMesh: false, + }, + clusterB: { + udp: [], + tcp: [], + xtls: [], + isVideoMesh: false, + }, + }, + joinCookie: {id: 'id'}, + }; + + reachability.reachabilityRequest.getClusters = sinon.stub().returns(mockGetClustersResult); + + const gatherReachabilityFallbackSpy = sinon.spy(reachability, 'gatherReachabilityFallback'); + + const resultPromise = reachability.gatherReachability('test'); + + await testUtils.flushPromises(); + + reachability.stopReachability(); + + await resultPromise; + + // simulate a lot of time passing to check that all timers were stopped and nothing else happens + clock.tick(99000); + + assert.calledOnceWithExactly(mockClusterReachabilityInstances['clusterA'].abort); + assert.calledOnceWithExactly(mockClusterReachabilityInstances['clusterB'].abort); + + assert.calledOnceWithExactly(sendMetricSpy, true); + + assert.equal(receivedEvents['reachability:stopped'], 1); + assert.equal(receivedEvents['reachability:done'], undefined); + assert.equal(receivedEvents['reachability:firstResultAvailable'], undefined); + + assert.notCalled(gatherReachabilityFallbackSpy); + }); }); }); @@ -2686,3 +2740,38 @@ describe('sendMetric', () => { }); }); }); + +describe('isSubnetReachable', () => { + let webex; + let reachability; + + beforeEach(() => { + webex = new MockWebex(); + reachability = new TestReachability(webex); + + reachability.setFakeClusterReachability({ + cluster1: { + reachedSubnets: new Set(['1.2.3.4', '2.3.4.5']), + }, + cluster2: { + reachedSubnets: new Set(['3.4.5.6', '4.5.6.7']), + }, + }); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('returns true if the subnet is reachable', () => { + assert(reachability.isSubnetReachable('1.2.3.4')); + }); + + it(`returns false if the subnet is unreachable`, () => { + assert(!reachability.isSubnetReachable('11.2.3.4')); + }); + + it('returns null if the subnet is not provided', () => { + assert.isNull(reachability.isSubnetReachable(undefined)); + }); +}); \ No newline at end of file diff --git a/packages/calling/package.json b/packages/calling/package.json index c3d79753331..8f99d6700cf 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.14.7", + "@webex/internal-media-core": "2.16.0", "@webex/internal-plugin-metrics": "workspace:*", "@webex/media-helpers": "workspace:*", "async-mutex": "0.4.0", diff --git a/packages/webex-node/package.json b/packages/webex-node/package.json index 8aa7d4d35ac..5e752add70c 100644 --- a/packages/webex-node/package.json +++ b/packages/webex-node/package.json @@ -53,6 +53,7 @@ "@webex/plugin-attachment-actions": "workspace:*", "@webex/plugin-authorization": "workspace:*", "@webex/plugin-device-manager": "workspace:*", + "@webex/plugin-encryption": "workspace:*", "@webex/plugin-logger": "workspace:*", "@webex/plugin-memberships": "workspace:*", "@webex/plugin-messages": "workspace:*", diff --git a/packages/webex-node/src/webex-node.js b/packages/webex-node/src/webex-node.js index 9d07aead7c3..b9d41835f42 100644 --- a/packages/webex-node/src/webex-node.js +++ b/packages/webex-node/src/webex-node.js @@ -24,6 +24,7 @@ require('@webex/plugin-rooms'); require('@webex/plugin-teams'); require('@webex/plugin-team-memberships'); require('@webex/plugin-webhooks'); +require('@webex/plugin-encryption'); const merge = require('lodash/merge'); const WebexCore = require('@webex/webex-core').default; diff --git a/packages/webex/package.json b/packages/webex/package.json index 3b75b69a591..069275693e1 100644 --- a/packages/webex/package.json +++ b/packages/webex/package.json @@ -6,7 +6,6 @@ "Adam Weeks (https://adamweeks.com/)", "Arun Ganeshan ", "Christopher DuBois (https://chrisadubois.github.io/)", - "Dipanshu Sharma ", "Kesava Krishnan Madavan ", "Priya Kesari ", "Rajesh Kumar ", @@ -17,9 +16,7 @@ "exports": { "./calling": "./dist/calling.js", ".": "./dist/index.js", - "./encryption": "./dist/encryption.js", "./meetings": "./dist/meetings.js", - "./contact-center": "./dist/contact-center.js", "./package": "./package.json" }, "devMain": "src/index.js", @@ -74,6 +71,7 @@ "@webex/internal-plugin-calendar": "workspace:*", "@webex/internal-plugin-device": "workspace:*", "@webex/internal-plugin-llm": "workspace:*", + "@webex/internal-plugin-mercury": "workspace:*", "@webex/internal-plugin-presence": "workspace:*", "@webex/internal-plugin-support": "workspace:*", "@webex/internal-plugin-voicea": "workspace:*", @@ -93,6 +91,7 @@ "@webex/plugin-webhooks": "workspace:*", "@webex/storage-adapter-local-storage": "workspace:*", "@webex/webex-core": "workspace:*", - "lodash": "^4.17.21" + "lodash": "^4.17.21", + "safe-buffer": "^5.2.0" } } diff --git a/packages/webex/src/contact-center.js b/packages/webex/src/contact-center.js deleted file mode 100644 index bd042a21639..00000000000 --- a/packages/webex/src/contact-center.js +++ /dev/null @@ -1,23 +0,0 @@ -import merge from 'lodash/merge'; -import WebexCore from '@webex/webex-core'; - -require('@webex/plugin-authorization'); -require('@webex/plugin-cc'); -require('@webex/internal-plugin-mercury'); -require('@webex/plugin-logger'); -require('@webex/internal-plugin-support'); - -const config = require('./config'); - -const Webex = WebexCore.extend({ - webex: true, - version: PACKAGE_VERSION, -}); - -Webex.init = function init(attrs = {}) { - attrs.config = merge({}, config, attrs.config); // eslint-disable-line no-param-reassign - - return new Webex(attrs); -}; - -export default Webex; diff --git a/packages/webex/src/webex.js b/packages/webex/src/webex.js index 8c0bc812b4d..99445c337b5 100644 --- a/packages/webex/src/webex.js +++ b/packages/webex/src/webex.js @@ -28,6 +28,7 @@ require('@webex/plugin-teams'); require('@webex/plugin-team-memberships'); require('@webex/plugin-webhooks'); require('@webex/plugin-encryption'); +require('@webex/plugin-cc'); const merge = require('lodash/merge'); const WebexCore = require('@webex/webex-core').default; diff --git a/webpack.config.js b/webpack.config.js index 0aef8acd88e..1ab4426a2c7 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -40,7 +40,7 @@ module.exports = (env = {NODE_ENV: process.env.NODE_ENV || 'production'}) => ({ }, }, encryption: { - import: `${path.resolve(__dirname)}/packages/webex/src/encryption.js`, + import: `${path.resolve(__dirname)}/packages/@webex/plugin-encryption/src/webex.js`, library: { name: 'Webex', type: 'umd', @@ -55,7 +55,7 @@ module.exports = (env = {NODE_ENV: process.env.NODE_ENV || 'production'}) => ({ }, }, 'contact-center': { - import: `${path.resolve(__dirname)}/packages/webex/src/contact-center.js`, + import: `${path.resolve(__dirname)}/packages/@webex/plugin-cc/src/webex.js`, library: { name: 'Webex', type: 'umd', diff --git a/yarn.lock b/yarn.lock index 40adc352457..b4f9ce60cee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4792,6 +4792,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-x64-gnu@npm:4.36.0": + version: 4.36.0 + resolution: "@rollup/rollup-linux-x64-gnu@npm:4.36.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-x64-musl@npm:4.19.0": version: 4.19.0 resolution: "@rollup/rollup-linux-x64-musl@npm:4.19.0" @@ -7820,7 +7827,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.14.7 + "@webex/internal-media-core": 2.16.0 "@webex/internal-plugin-metrics": "workspace:*" "@webex/media-helpers": "workspace:*" async-mutex: 0.4.0 @@ -8020,17 +8027,16 @@ __metadata: languageName: unknown linkType: soft -"@webex/event-dictionary-ts@npm:^1.0.1688": - version: 1.0.1688 - resolution: "@webex/event-dictionary-ts@npm:1.0.1688" +"@webex/event-dictionary-ts@npm:^1.0.1753": + version: 1.0.1753 + resolution: "@webex/event-dictionary-ts@npm:1.0.1753" dependencies: amf-client-js: ^5.2.6 json-schema-to-typescript: ^12.0.0 minimist: ^1.2.8 - ramldt2jsonschema: ^1.2.3 shelljs: ^0.8.5 webapi-parser: ^0.5.0 - checksum: efc5ee43367595c8943416997bfb6b63b2718163c9432f55974bb79af664daa1281453e270437def85fabd2c44cfea4dca349f894b006c8038c51126206cbff2 + checksum: f0a6daf8ed4b3ba9509c69b4bb79fe5c4d8bdf78ef25755cc7a9ce9a753f72f179576c19a5062ba247b8cf68948bf35420f8405fc5c9935c4df876f544d0490d languageName: node linkType: hard @@ -8112,22 +8118,23 @@ __metadata: languageName: unknown linkType: soft -"@webex/internal-media-core@npm:2.14.7": - version: 2.14.7 - resolution: "@webex/internal-media-core@npm:2.14.7" +"@webex/internal-media-core@npm:2.16.0": + version: 2.16.0 + resolution: "@webex/internal-media-core@npm:2.16.0" dependencies: "@babel/runtime": ^7.18.9 "@babel/runtime-corejs2": ^7.25.0 "@webex/rtcstats": ^1.5.0 "@webex/ts-sdp": 1.7.0 "@webex/web-capabilities": ^1.4.1 - "@webex/web-client-media-engine": 3.30.2 + "@webex/web-client-media-engine": 3.31.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: 74aa21cafd03892a572a7a8538d0e0e3df4a094ed2921ef95bd7bea6d02b515f3af2aa4b4c2c2946339e5fdae72f1aa07fe94b71f09fb755ba8867b634d68bc4 + checksum: fc2cf77ee94738e17e0823ab7bc3954243204762e40d54fd3ffb8ed57bf8d9babd2a847f027f2cf81666540c5711613c01066f2cc21169b1ec260b93eef9396d languageName: node linkType: hard @@ -8529,7 +8536,7 @@ __metadata: "@webex/common": "workspace:*" "@webex/common-timers": "workspace:*" "@webex/eslint-config-legacy": "workspace:*" - "@webex/event-dictionary-ts": ^1.0.1688 + "@webex/event-dictionary-ts": ^1.0.1753 "@webex/internal-plugin-metrics": "workspace:*" "@webex/jest-config-legacy": "workspace:*" "@webex/legacy-tools": "workspace:*" @@ -8811,6 +8818,18 @@ __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/legacy-tools@workspace:*, @webex/legacy-tools@workspace:packages/legacy/tools": version: 0.0.0-use.local resolution: "@webex/legacy-tools@workspace:packages/legacy/tools" @@ -8887,13 +8906,13 @@ __metadata: "@babel/preset-typescript": 7.22.11 "@webex/babel-config-legacy": "workspace:*" "@webex/eslint-config-legacy": "workspace:*" - "@webex/internal-media-core": 2.14.7 + "@webex/internal-media-core": 2.16.0 "@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.19.0 + "@webex/web-media-effects": 2.27.1 eslint: ^8.24.0 jsdom-global: 3.0.2 sinon: ^9.2.4 @@ -9079,11 +9098,13 @@ __metadata: "@webex/eslint-config-legacy": "workspace:*" "@webex/internal-plugin-mercury": "workspace:*" "@webex/internal-plugin-metrics": "workspace:*" + "@webex/internal-plugin-support": "workspace:*" "@webex/jest-config-legacy": "workspace:*" "@webex/legacy-tools": "workspace:*" + "@webex/plugin-authorization": "workspace:*" + "@webex/plugin-logger": "workspace:*" "@webex/test-helper-mock-webex": "workspace:*" "@webex/webex-core": "workspace:*" - buffer: 6.0.3 eslint: ^8.24.0 eslint-config-airbnb-base: 15.0.0 eslint-config-prettier: 8.3.0 @@ -9095,6 +9116,7 @@ __metadata: jest: 27.5.1 jest-html-reporters: 3.0.11 jest-junit: 13.0.0 + lodash: ^4.17.21 prettier: 2.5.1 typedoc: 0.23.26 typescript: 4.9.5 @@ -9184,8 +9206,8 @@ __metadata: "@webex/babel-config-legacy": "workspace:*" "@webex/common": "workspace:*" "@webex/eslint-config-legacy": "workspace:*" - "@webex/event-dictionary-ts": ^1.0.1688 - "@webex/internal-media-core": 2.14.7 + "@webex/event-dictionary-ts": ^1.0.1753 + "@webex/internal-media-core": 2.16.0 "@webex/internal-plugin-conversation": "workspace:*" "@webex/internal-plugin-device": "workspace:*" "@webex/internal-plugin-llm": "workspace:*" @@ -9205,6 +9227,7 @@ __metadata: "@webex/test-helper-mock-webex": "workspace:*" "@webex/test-helper-retry": "workspace:*" "@webex/test-helper-test-users": "workspace:*" + "@webex/ts-sdp": ^1.8.1 "@webex/web-capabilities": ^1.4.0 "@webex/webex-core": "workspace:*" ampersand-collection: ^2.0.2 @@ -9882,7 +9905,7 @@ __metadata: languageName: node linkType: hard -"@webex/ts-sdp@npm:1.8.1": +"@webex/ts-sdp@npm:1.8.1, @webex/ts-sdp@npm:^1.8.1": version: 1.8.1 resolution: "@webex/ts-sdp@npm:1.8.1" checksum: de9d23826c27b31f35907fa8066ff4d762fe72e6fb19e43d74dc28261d3bd126b181e057a341d4ab41c64f267c7341608be25ed186e2cc7d444a6efc940625d7 @@ -9904,9 +9927,9 @@ __metadata: languageName: node linkType: hard -"@webex/web-client-media-engine@npm:3.30.2": - version: 3.30.2 - resolution: "@webex/web-client-media-engine@npm:3.30.2" +"@webex/web-client-media-engine@npm:3.31.0": + version: 3.31.0 + resolution: "@webex/web-client-media-engine@npm:3.31.0" dependencies: "@webex/json-multistream": ^2.2.1 "@webex/rtcstats": ^1.5.0 @@ -9919,7 +9942,7 @@ __metadata: js-logger: ^1.6.1 typed-emitter: ^2.1.0 uuid: ^8.3.2 - checksum: 04827fb93a4458b2ed14d1ff112fd5c7398cfe5b4a924a5a58b3a89f211ccd4991c62b6ee3bed5f90b8f81ce6b1074d372da76b25b52e6e7bfb720e9a910027d + checksum: 4e7b18ae72862c48f339f70a6d0f57f33d30f801edf23926ec9d63e72c2335f473396af5d419028f78e95cd606d2e2f3ebc46e9d384dd13881310f76b8e4a45b languageName: node linkType: hard @@ -9936,17 +9959,18 @@ __metadata: languageName: node linkType: hard -"@webex/web-media-effects@npm:2.19.0": - version: 2.19.0 - resolution: "@webex/web-media-effects@npm:2.19.0" +"@webex/web-media-effects@npm:2.27.1": + version: 2.27.1 + resolution: "@webex/web-media-effects@npm:2.27.1" dependencies: - "@webex/ladon-ts": ^4.3.0 + "@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 - checksum: 04c100b8eb01fbe05cc5ee1b1898c54f2ef537a9e367c7693767f6fb1df1d085c00ba91ee42d7bb9e9df55baeccd43d4ea979dcd486223a4dc3a82c61769494f + yarn: ^1.22.22 + checksum: e8bfb73860bfc25fa730d13b713a5cf3851d861cf6616343f4ce84d68cb085f2d2251e6788735f4671499324bee6e35b9d708fe1fdb0770695c116e2b55227e6 languageName: node linkType: hard @@ -10447,18 +10471,6 @@ __metadata: languageName: node linkType: hard -"ajv@npm:^5.0.0": - version: 5.5.2 - resolution: "ajv@npm:5.5.2" - dependencies: - co: "npm:^4.6.0" - fast-deep-equal: "npm:^1.0.0" - fast-json-stable-stringify: "npm:^2.0.0" - json-schema-traverse: "npm:^0.3.0" - checksum: a69645c843e1676b0ae1c5192786e546427f808f386d26127c6585479378066c64341ceec0b127b6789d79628e71d2a732d402f575b98f9262db230d7b715a94 - languageName: node - linkType: hard - "ajv@npm:^8.0.0, ajv@npm:^8.11.0, ajv@npm:^8.9.0": version: 8.12.0 resolution: "ajv@npm:8.12.0" @@ -13549,13 +13561,6 @@ __metadata: languageName: node linkType: hard -"commander@npm:^5.0.0": - version: 5.1.0 - resolution: "commander@npm:5.1.0" - checksum: 0b7fec1712fbcc6230fcb161d8d73b4730fa91a21dc089515489402ad78810547683f058e2a9835929c212fead1d6a6ade70db28bbb03edbc2829a9ab7d69447 - languageName: node - linkType: hard - "commander@npm:^7.0.0, commander@npm:^7.2.0": version: 7.2.0 resolution: "commander@npm:7.2.0" @@ -17772,13 +17777,6 @@ __metadata: languageName: node linkType: hard -"fast-deep-equal@npm:^1.0.0": - version: 1.1.0 - resolution: "fast-deep-equal@npm:1.1.0" - checksum: 69b4c9534d9805f13a341aa72f69641d0b9ae3cc8beb25c64e68a257241c7bb34370266db27ae4fc3c4da0518448c01a5f587a096a211471c86a38facd9a1486 - languageName: node - linkType: hard - "fast-deep-equal@npm:^2.0.1": version: 2.0.1 resolution: "fast-deep-equal@npm:2.0.1" @@ -22717,7 +22715,7 @@ __metadata: languageName: node linkType: hard -"js-yaml@npm:3.x, js-yaml@npm:^3.13.1, js-yaml@npm:^3.14.0": +"js-yaml@npm:3.x, js-yaml@npm:^3.13.1": version: 3.14.1 resolution: "js-yaml@npm:3.14.1" dependencies: @@ -22977,15 +22975,6 @@ __metadata: languageName: node linkType: hard -"json-schema-migrate@npm:^0.2.0": - version: 0.2.0 - resolution: "json-schema-migrate@npm:0.2.0" - dependencies: - ajv: "npm:^5.0.0" - checksum: fc5b3ed2cfc5d02059836120653b63f846a18037ec554b47267f9c104750f8fa3f3acb38d0129f116fd2db017c69cc90456cfae66930aa1d328fe12a422c0125 - languageName: node - linkType: hard - "json-schema-to-typescript@npm:^12.0.0": version: 12.0.0 resolution: "json-schema-to-typescript@npm:12.0.0" @@ -23010,13 +22999,6 @@ __metadata: languageName: node linkType: hard -"json-schema-traverse@npm:^0.3.0": - version: 0.3.1 - resolution: "json-schema-traverse@npm:0.3.1" - checksum: a685c36222023471c25c86cddcff506306ecb8f8941922fd356008419889c41c38e1c16d661d5499d0a561b34f417693e9bb9212ba2b2b2f8f8a345a49e4ec1a - languageName: node - linkType: hard - "json-schema-traverse@npm:^0.4.1": version: 0.4.1 resolution: "json-schema-traverse@npm:0.4.1" @@ -28444,21 +28426,6 @@ __metadata: languageName: node linkType: hard -"ramldt2jsonschema@npm:^1.2.3": - version: 1.2.3 - resolution: "ramldt2jsonschema@npm:1.2.3" - dependencies: - commander: "npm:^5.0.0" - js-yaml: "npm:^3.14.0" - json-schema-migrate: "npm:^0.2.0" - webapi-parser: "npm:^0.5.0" - bin: - dt2js: bin/dt2js.js - js2dt: bin/js2dt.js - checksum: db6b89f4c088184290ea27448cfd64985e253543209e0b80bfb12b0b9640253b59418b671164af9055143018ef4be010c5f52ca853386636267269208f2f3512 - languageName: node - linkType: hard - "random-bytes@npm:~1.0.0": version: 1.0.0 resolution: "random-bytes@npm:1.0.0" @@ -34246,6 +34213,7 @@ __metadata: "@webex/plugin-attachment-actions": "workspace:*" "@webex/plugin-authorization": "workspace:*" "@webex/plugin-device-manager": "workspace:*" + "@webex/plugin-encryption": "workspace:*" "@webex/plugin-logger": "workspace:*" "@webex/plugin-memberships": "workspace:*" "@webex/plugin-messages": "workspace:*" @@ -34280,6 +34248,7 @@ __metadata: "@webex/internal-plugin-calendar": "workspace:*" "@webex/internal-plugin-device": "workspace:*" "@webex/internal-plugin-llm": "workspace:*" + "@webex/internal-plugin-mercury": "workspace:*" "@webex/internal-plugin-presence": "workspace:*" "@webex/internal-plugin-support": "workspace:*" "@webex/internal-plugin-voicea": "workspace:*" @@ -34308,6 +34277,7 @@ __metadata: "@webex/webex-core": "workspace:*" eslint: ^8.24.0 lodash: ^4.17.21 + safe-buffer: ^5.2.0 sinon: ^9.2.4 webex: "workspace:*" languageName: unknown @@ -35214,6 +35184,16 @@ __metadata: languageName: node linkType: hard +"yarn@npm:^1.22.22": + version: 1.22.22 + resolution: "yarn@npm:1.22.22" + bin: + yarn: bin/yarn.js + yarnpkg: bin/yarn.js + checksum: 59aeef5ccfd3347287f939448e6d3594f0a42f74025b9bdc2a277641c1d4070c07a38b6e7c35e695f77410b0269a5a43c78535786564f86f39c9f781e6efa311 + languageName: node + linkType: hard + "yauzl@npm:3.1.3": version: 3.1.3 resolution: "yauzl@npm:3.1.3" From 70fd25c7f39e06bdc44b1b8368580512e5e55e8f Mon Sep 17 00:00:00 2001 From: Adhwaith Menon <111346225+adhmenon@users.noreply.github.com> Date: Fri, 30 May 2025 20:44:14 +0530 Subject: [PATCH 2/8] feat(plugin-cc): added initial task skeleton (#4287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Priya Co-authored-by: robstax Co-authored-by: rstachof Co-authored-by: Coread Co-authored-by: László Vadász Co-authored-by: chrisadubois Co-authored-by: Kesava Krishnan Madavan Co-authored-by: Kacper Waśniowski Co-authored-by: Edmond Vujići <67634227+edvujic@users.noreply.github.com> Co-authored-by: Sreekanth Narayanan <131740035+sreenara@users.noreply.github.com> Co-authored-by: Peter Cole <55573154+peter7cole@users.noreply.github.com> Co-authored-by: rsarika <95286093+rsarika@users.noreply.github.com> Co-authored-by: akulakum <74420487+akulakum@users.noreply.github.com> Co-authored-by: Kesari3008 <65543166+Kesari3008@users.noreply.github.com> Co-authored-by: Bharath Balan <62698609+bhabalan@users.noreply.github.com> Co-authored-by: Marcin Co-authored-by: Bryce Tham Co-authored-by: shivani1211 Co-authored-by: mickelr <121160648+mickelr@users.noreply.github.com> --- packages/@webex/plugin-cc/src/cc.ts | 10 +- .../plugin-cc/src/services/task/Task.ts | 269 ++++++++++++++++++ .../src/services/task/TaskFactory.ts | 43 +++ .../src/services/task/TaskManager.ts | 170 +++++------ .../src/services/task/digital/Digital.ts | 27 ++ .../plugin-cc/src/services/task/index.ts | 4 +- .../plugin-cc/src/services/task/types.ts | 201 ++++++++++++- .../src/services/task/voice/Voice.ts | 173 +++++++++++ .../src/services/task/voice/WebRTC.ts | 186 ++++++++++++ packages/@webex/plugin-cc/src/types.ts | 12 + .../@webex/plugin-cc/test/unit/spec/cc.ts | 7 + .../test/unit/spec/services/task/Task.ts | 203 +++++++++++++ .../unit/spec/services/task/TaskFactory.ts | 57 ++++ .../unit/spec/services/task/TaskManager.ts | 109 +++++-- .../spec/services/task/digital/Digital.ts | 31 ++ .../unit/spec/services/task/voice/Voice.ts | 104 +++++++ .../unit/spec/services/task/voice/WebRTC.ts | 76 +++++ 17 files changed, 1544 insertions(+), 138 deletions(-) create mode 100644 packages/@webex/plugin-cc/src/services/task/Task.ts create mode 100644 packages/@webex/plugin-cc/src/services/task/TaskFactory.ts create mode 100644 packages/@webex/plugin-cc/src/services/task/digital/Digital.ts create mode 100644 packages/@webex/plugin-cc/src/services/task/voice/Voice.ts create mode 100644 packages/@webex/plugin-cc/src/services/task/voice/WebRTC.ts create mode 100644 packages/@webex/plugin-cc/test/unit/spec/services/task/Task.ts create mode 100644 packages/@webex/plugin-cc/test/unit/spec/services/task/TaskFactory.ts create mode 100644 packages/@webex/plugin-cc/test/unit/spec/services/task/digital/Digital.ts create mode 100644 packages/@webex/plugin-cc/test/unit/spec/services/task/voice/Voice.ts create mode 100644 packages/@webex/plugin-cc/test/unit/spec/services/task/voice/WebRTC.ts diff --git a/packages/@webex/plugin-cc/src/cc.ts b/packages/@webex/plugin-cc/src/cc.ts index 62b824957fd..5a780d6203d 100644 --- a/packages/@webex/plugin-cc/src/cc.ts +++ b/packages/@webex/plugin-cc/src/cc.ts @@ -18,6 +18,7 @@ import { UploadLogsResponse, UpdateDeviceTypeResponse, GenericError, + ConfigFlags, } from './types'; import { READY, @@ -46,7 +47,7 @@ import { import {ConnectionLostDetails} from './services/core/websocket/types'; import TaskManager from './services/task/TaskManager'; import WebCallingService from './services/WebCallingService'; -import {ITask, TASK_EVENTS, TaskResponse, DialerPayload} from './services/task/types'; +import {TASK_EVENTS, TaskResponse, DialerPayload, ITask} from './services/task/types'; import MetricsManager from './metrics/MetricsManager'; import {METRIC_EVENT_NAMES} from './metrics/constants'; import {Failure} from './services/core/GlobalTypes'; @@ -131,6 +132,13 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter this.setupEventListeners(); const resp = await this.connectWebsocket(); + const configFlags: ConfigFlags = { + isEndCallEnabled: this.agentConfig.isEndCallEnabled, + isEndConsultEnabled: this.agentConfig.isEndConsultEnabled, + webRtcEnabled: this.agentConfig.webRtcEnabled, + autoWrapup: this.agentConfig.wrapUpData?.wrapUpProps?.autoWrapup ?? false, + }; + this.taskManager.setConfigFlags(configFlags); this.metricsManager.trackEvent( METRIC_EVENT_NAMES.WEBSOCKET_REGISTER_SUCCESS, { diff --git a/packages/@webex/plugin-cc/src/services/task/Task.ts b/packages/@webex/plugin-cc/src/services/task/Task.ts new file mode 100644 index 00000000000..70988763f21 --- /dev/null +++ b/packages/@webex/plugin-cc/src/services/task/Task.ts @@ -0,0 +1,269 @@ +import {EventEmitter} from 'events'; +import {CallId} from '@webex/calling/dist/types/common/types'; +import { + ITask, + TaskData, + TaskResponse, + WrapupPayLoad, + TaskId, + TransferPayLoad, + TaskButtonControl, + TaskUIControls, + DESTINATION_TYPE, +} from './types'; +import {CC_FILE} from '../../constants'; +import {getErrorDetails} from '../core/Utils'; +import routingContact from './contact'; +import MetricsManager from '../../metrics/MetricsManager'; +import {METRIC_EVENT_NAMES} from '../../metrics/constants'; +import LoggerProxy from '../../logger-proxy'; + +export default abstract class Task extends EventEmitter implements ITask { + protected contact: ReturnType; + protected metricsManager: MetricsManager; + public data: TaskData; + public webCallMap: Record; + public taskUiControls: TaskUIControls; + + constructor(contact: ReturnType, data: TaskData) { + super(); + this.contact = contact; + this.data = data; + this.metricsManager = MetricsManager.getInstance(); + this.webCallMap = {}; + this.initialiseUIControls(); + } + + private reconcileData(oldData: TaskData, newData: TaskData): TaskData { + Object.keys(newData).forEach((key) => { + if (newData[key] && typeof newData[key] === 'object' && !Array.isArray(newData[key])) { + oldData[key] = this.reconcileData({...oldData[key]}, newData[key]); + } else { + oldData[key] = newData[key]; + } + }); + + return oldData; + } + + private initialiseUIControls() { + this.taskUiControls = { + accept: new TaskButtonControl(true, true), + decline: new TaskButtonControl(true, true), + hold: new TaskButtonControl(false, false), + mute: new TaskButtonControl(false, false), + end: new TaskButtonControl(true, true), + transfer: new TaskButtonControl(true, true), + consult: new TaskButtonControl(false, false), + consultTransfer: new TaskButtonControl(false, false), + endConsult: new TaskButtonControl(false, false), + recording: new TaskButtonControl(false, false), + conference: new TaskButtonControl(false, false), + wrapup: new TaskButtonControl(false, false), + }; + } + + /** + * This method is used to set the UI controls data. Will be implemented in child classes. + */ + protected setUIControls() {} + + /** + * This method is used to update the task data. + * @param updatedData - TaskData + * @param shouldOverwrite - boolean + * @returns Task + * @example + * ```typescript + * task.updateTaskData(updatedData, true); + * ``` + */ + public updateTaskData(updatedData: TaskData, shouldOverwrite = false) { + this.data = shouldOverwrite ? updatedData : this.reconcileData(this.data, updatedData); + this.setUIControls(); + } + + public abstract accept(): Promise; + + /** + * This is used to blind transfer or vTeam transfer the task + * @param transferPayload + * @returns Promise + * @throws Error + * @example + * ```typescript + * const transferPayload = { + * to: 'myQueueId', + * destinationType: 'queue', + * } + * task.transfer(transferPayload).then(()=>{}).catch(()=>{}); + * ``` + */ + public async transfer(transferPayload: TransferPayLoad): Promise { + LoggerProxy.log(`Starting task transfer for taskId:${this.data.interactionId}`, { + module: 'Task', + method: 'transfer', + }); + try { + this.metricsManager.timeEvent([ + METRIC_EVENT_NAMES.TASK_TRANSFER_SUCCESS, + METRIC_EVENT_NAMES.TASK_TRANSFER_FAILED, + ]); + let result: TaskResponse; + if (transferPayload.destinationType === DESTINATION_TYPE.QUEUE) { + result = await this.contact.vteamTransfer({ + interactionId: this.data.interactionId, + data: transferPayload, + }); + } else { + result = await this.contact.blindTransfer({ + interactionId: this.data.interactionId, + data: transferPayload, + }); + } + + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.TASK_TRANSFER_SUCCESS, + { + taskId: this.data.interactionId, + destination: transferPayload.to, + destinationType: transferPayload.destinationType, + isConsultTransfer: false, + ...MetricsManager.getCommonTrackingFieldForAQMResponse(result), + }, + ['operational', 'behavioral', 'business'] + ); + + return result; + } catch (error) { + const {error: detailedError} = getErrorDetails(error, 'transfer', CC_FILE); + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.TASK_TRANSFER_FAILED, + { + taskId: this.data.interactionId, + destination: transferPayload.to, + destinationType: transferPayload.destinationType, + isConsultTransfer: false, + error: error.toString(), + ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed( + (error as any).details || {} + ), + }, + ['operational', 'behavioral', 'business'] + ); + throw detailedError; + } + } + + /** + * This is used to end the task. + * @returns Promise + * @throws Error + * @example + * ```typescript + * task.end().then(()=>{}).catch(()=>{}) + * ``` + */ + public async end(): Promise { + LoggerProxy.log(`Ending task for taskId:${this.data.interactionId}`, { + module: 'Task', + method: 'end', + }); + try { + this.metricsManager.timeEvent([ + METRIC_EVENT_NAMES.TASK_END_SUCCESS, + METRIC_EVENT_NAMES.TASK_END_FAILED, + ]); + const response = await this.contact.end({interactionId: this.data.interactionId}); + + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.TASK_END_SUCCESS, + { + taskId: this.data.interactionId, + ...MetricsManager.getCommonTrackingFieldForAQMResponse(response), + }, + ['operational', 'behavioral', 'business'] + ); + + return response; + } catch (error) { + const {error: detailedError} = getErrorDetails(error, 'end', CC_FILE); + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.TASK_END_FAILED, + { + taskId: this.data.interactionId, + ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed( + (error as any).details || {} + ), + }, + ['operational', 'behavioral', 'business'] + ); + throw detailedError; + } + } + + /** + * This is used to wrap up the task. + * @param wrapupPayload - WrapupPayLoad + * @returns Promise + * @throws Error + * @example + * ```typescript + * task.wrapup(wrapupPayload).then(()=>{}).catch(()=>{}) + * ``` + */ + public async wrapup(wrapupPayload: WrapupPayLoad): Promise { + LoggerProxy.log(`Starting task wrapup for taskId:${this.data.interactionId}`, { + module: 'Task', + method: 'wrapup', + }); + try { + this.metricsManager.timeEvent([ + METRIC_EVENT_NAMES.TASK_WRAPUP_SUCCESS, + METRIC_EVENT_NAMES.TASK_WRAPUP_FAILED, + ]); + if (!this.data) { + throw new Error('No task data available'); + } + if (!wrapupPayload.auxCodeId || wrapupPayload.auxCodeId.length === 0) { + throw new Error('AuxCodeId is required'); + } + if (!wrapupPayload.wrapUpReason || wrapupPayload.wrapUpReason.length === 0) { + throw new Error('WrapUpReason is required'); + } + + const response = await this.contact.wrapup({ + interactionId: this.data.interactionId, + data: wrapupPayload, + }); + + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.TASK_WRAPUP_SUCCESS, + { + taskId: this.data.interactionId, + wrapUpCode: wrapupPayload.auxCodeId, + wrapUpReason: wrapupPayload.wrapUpReason, + ...MetricsManager.getCommonTrackingFieldForAQMResponse(response), + }, + ['operational', 'behavioral', 'business'] + ); + + return response; + } catch (error) { + const {error: detailedError} = getErrorDetails(error, 'wrapup', CC_FILE); + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.TASK_WRAPUP_FAILED, + { + taskId: this.data.interactionId, + wrapUpCode: wrapupPayload.auxCodeId, + wrapUpReason: wrapupPayload.wrapUpReason, + ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed( + (error as any).details || {} + ), + }, + ['operational', 'behavioral', 'business'] + ); + throw detailedError; + } + } +} diff --git a/packages/@webex/plugin-cc/src/services/task/TaskFactory.ts b/packages/@webex/plugin-cc/src/services/task/TaskFactory.ts new file mode 100644 index 00000000000..0594a337d85 --- /dev/null +++ b/packages/@webex/plugin-cc/src/services/task/TaskFactory.ts @@ -0,0 +1,43 @@ +import routingContact from './contact'; +import WebCallingService from '../WebCallingService'; +import Task from './Task'; +import Voice from './voice/Voice'; +import WebRTC from './voice/WebRTC'; +import Digital from './digital/Digital'; +import {MEDIA_CHANNEL, TaskData} from './types'; +import {ConfigFlags} from '../../types'; + +export default class TaskFactory { + /** + * Creates the correct Task subclass based on mediaType & loginOption + */ + public static createTask( + contact: ReturnType, + webCallingService: WebCallingService, + data: TaskData, + configFlags: ConfigFlags + ): Task { + const mediaType = data.interaction.mediaType ?? MEDIA_CHANNEL.TELEPHONY; + const {isEndCallEnabled, isEndConsultEnabled} = configFlags; + + switch (mediaType) { + case MEDIA_CHANNEL.TELEPHONY: + if (webCallingService.loginOption === 'BROWSER') { + return new WebRTC(contact, webCallingService, data); + } + + return new Voice(contact, data, { + isEndCallEnabled, + isEndConsultEnabled, + }); + + case MEDIA_CHANNEL.CHAT: + case MEDIA_CHANNEL.EMAIL: + case MEDIA_CHANNEL.SOCIAL: + return new Digital(contact, data); + + default: + throw new Error(`Unknown media type: ${mediaType}`); + } + } +} diff --git a/packages/@webex/plugin-cc/src/services/task/TaskManager.ts b/packages/@webex/plugin-cc/src/services/task/TaskManager.ts index 9769e8969f8..60539f51246 100644 --- a/packages/@webex/plugin-cc/src/services/task/TaskManager.ts +++ b/packages/@webex/plugin-cc/src/services/task/TaskManager.ts @@ -3,14 +3,15 @@ import {ICall, LINE_EVENTS} from '@webex/calling'; import {WebSocketManager} from '../core/websocket/WebSocketManager'; import routingContact from './contact'; import WebCallingService from '../WebCallingService'; -import {ITask, MEDIA_CHANNEL, TASK_EVENTS, TaskData, TaskId} from './types'; +import {MEDIA_CHANNEL, TASK_EVENTS, TaskData, TaskId, ITask} from './types'; import {TASK_MANAGER_FILE} from '../../constants'; import {CC_EVENTS, CC_TASK_EVENTS} from '../config/types'; -import {LoginOption} from '../../types'; +import {ConfigFlags, LoginOption} from '../../types'; import LoggerProxy from '../../logger-proxy'; -import Task from '.'; import MetricsManager from '../../metrics/MetricsManager'; import {METRIC_EVENT_NAMES} from '../../metrics/constants'; +import TaskFactory from './TaskFactory'; +import WebRTC from './voice/WebRTC'; export default class TaskManager extends EventEmitter { private call: ICall; @@ -19,13 +20,9 @@ export default class TaskManager extends EventEmitter { private webCallingService: WebCallingService; private webSocketManager: WebSocketManager; private metricsManager: MetricsManager; - private static taskManager; + private static taskManager: TaskManager; + private configFlags?: ConfigFlags; - /** - * @param contact - Routing Contact layer. Talks to AQMReq layer to convert events to promises - * @param webCallingService - Webrtc Service Layer - * @param webSocketManager - Websocket Manager to maintain websocket connection and keepalives - */ constructor( contact: ReturnType, webCallingService: WebCallingService, @@ -33,17 +30,18 @@ export default class TaskManager extends EventEmitter { ) { super(); this.contact = contact; - this.taskCollection = {}; this.webCallingService = webCallingService; this.webSocketManager = webSocketManager; + this.taskCollection = {}; this.metricsManager = MetricsManager.getInstance(); + this.registerTaskListeners(); this.registerIncomingCallEvent(); } private handleIncomingWebCall = (call: ICall) => { const currentTask = Object.values(this.taskCollection).find( - (task) => task.data.interaction.mediaType === 'telephony' + (t) => t.data.interaction.mediaType === MEDIA_CHANNEL.TELEPHONY ); if (currentTask) { @@ -57,6 +55,13 @@ export default class TaskManager extends EventEmitter { this.call = call; }; + /** + * Inject agent profile after instantiation + */ + public setConfigFlags(configFlags: ConfigFlags): void { + this.configFlags = configFlags; + } + public registerIncomingCallEvent() { this.webCallingService.on(LINE_EVENTS.INCOMING_CALL, this.handleIncomingWebCall); } @@ -66,42 +71,40 @@ export default class TaskManager extends EventEmitter { } private registerTaskListeners() { - this.webSocketManager.on('message', (event) => { + this.webSocketManager.on('message', (event: string) => { const payload = JSON.parse(event); - // Re-emit the task events to the task object let task: ITask; + if (payload.data?.type) { + // for events emitted on existing tasks if (Object.values(CC_TASK_EVENTS).includes(payload.data.type)) { task = this.taskCollection[payload.data.interactionId]; } switch (payload.data.type) { case CC_EVENTS.AGENT_CONTACT: - task = new Task(this.contact, this.webCallingService, { - ...payload.data, - wrapUpRequired: - payload.data.interaction?.participants?.[payload.data.agentId]?.isWrapUp || false, - }); this.taskCollection[payload.data.interactionId] = task; this.emit(TASK_EVENTS.TASK_HYDRATE, task); break; + case CC_EVENTS.AGENT_CONTACT_RESERVED: - task = new Task(this.contact, this.webCallingService, { - ...payload.data, - isConsulted: false, - }); // Ensure isConsulted prop exists + task = TaskFactory.createTask( + this.contact, + this.webCallingService, + {...payload.data, isConsulted: false}, + this.configFlags + ); this.taskCollection[payload.data.interactionId] = task; + // for telephony in-browser we wait for incoming call, else fire immediately if ( this.webCallingService.loginOption !== LoginOption.BROWSER || - task.data.interaction.mediaType !== MEDIA_CHANNEL.TELEPHONY // for digital channels + task.data.interaction.mediaType !== MEDIA_CHANNEL.TELEPHONY || + this.call ) { this.emit(TASK_EVENTS.TASK_INCOMING, task); - } else if (this.call) { - this.emit(TASK_EVENTS.TASK_INCOMING, task); } break; case CC_EVENTS.AGENT_OFFER_CONTACT: - // We don't have to emit any event here since this will be result of promise. - task = this.updateTaskData(task, payload.data); + this.updateTaskData(task, payload.data); LoggerProxy.log('Agent offer contact', { module: TASK_MANAGER_FILE, method: 'registerTaskListeners', @@ -109,7 +112,6 @@ export default class TaskManager extends EventEmitter { this.emit(TASK_EVENTS.TASK_OFFER_CONTACT, task); break; case CC_EVENTS.AGENT_OUTBOUND_FAILED: - // We don't have to emit any event here since this will be result of promise. if (task.data) { this.removeTaskFromCollection(task); } @@ -119,18 +121,18 @@ export default class TaskManager extends EventEmitter { }); break; case CC_EVENTS.AGENT_CONTACT_ASSIGNED: - task = this.updateTaskData(task, payload.data); + this.updateTaskData(task, payload.data); task.emit(TASK_EVENTS.TASK_ASSIGNED, task); break; case CC_EVENTS.AGENT_CONTACT_UNASSIGNED: - task = this.updateTaskData(task, { + this.updateTaskData(task, { ...payload.data, wrapUpRequired: true, }); task.emit(TASK_EVENTS.TASK_END, task); break; case CC_EVENTS.AGENT_CONTACT_OFFER_RONA: - task = this.updateTaskData(task, payload.data); + this.updateTaskData(task, payload.data); this.metricsManager.trackEvent( METRIC_EVENT_NAMES.AGENT_RONA, { @@ -145,7 +147,7 @@ export default class TaskManager extends EventEmitter { break; case CC_EVENTS.CONTACT_ENDED: case CC_EVENTS.AGENT_INVITE_FAILED: - task = this.updateTaskData(task, { + this.updateTaskData(task, { ...payload.data, wrapUpRequired: payload.data.interaction.state !== 'new', }); @@ -155,28 +157,28 @@ export default class TaskManager extends EventEmitter { break; case CC_EVENTS.AGENT_CONTACT_HELD: // As soon as the main interaction is held, we need to emit TASK_HOLD - task = this.updateTaskData(task, payload.data); + this.updateTaskData(task, payload.data); task.emit(TASK_EVENTS.TASK_HOLD, task); break; case CC_EVENTS.AGENT_CONTACT_UNHELD: // As soon as the main interaction is unheld, we need to emit TASK_RESUME - task = this.updateTaskData(task, payload.data); + this.updateTaskData(task, payload.data); task.emit(TASK_EVENTS.TASK_RESUME, task); break; case CC_EVENTS.AGENT_VTEAM_TRANSFERRED: - task = this.updateTaskData(task, { + this.updateTaskData(task, { ...payload.data, wrapUpRequired: true, }); task.emit(TASK_EVENTS.TASK_END, task); break; case CC_EVENTS.AGENT_CTQ_CANCEL_FAILED: - task = this.updateTaskData(task, payload.data); + this.updateTaskData(task, payload.data); task.emit(TASK_EVENTS.TASK_CONSULT_QUEUE_FAILED, task); break; case CC_EVENTS.AGENT_CONSULT_CREATED: // Received when self agent initiates a consult - task = this.updateTaskData(task, { + this.updateTaskData(task, { ...payload.data, isConsulted: false, // This ensures that the task consult status is always reset }); @@ -184,7 +186,7 @@ export default class TaskManager extends EventEmitter { break; case CC_EVENTS.AGENT_OFFER_CONSULT: // Received when other agent sends us a consult offer - task = this.updateTaskData(task, { + this.updateTaskData(task, { ...payload.data, isConsulted: true, // This ensures that the task is marked as us being requested for a consult }); @@ -192,7 +194,7 @@ export default class TaskManager extends EventEmitter { break; case CC_EVENTS.AGENT_CONSULTING: // Received when agent is in an active consult state - task = this.updateTaskData(task, payload.data); + this.updateTaskData(task, payload.data); if (task.data.isConsulted) { // Fire only if you are the agent who received the consult request task.emit(TASK_EVENTS.TASK_CONSULT_ACCEPTED, task); @@ -204,10 +206,10 @@ export default class TaskManager extends EventEmitter { case CC_EVENTS.AGENT_CONSULT_FAILED: // This can only be received by the agent who initiated the consult. // We need not emit any event here since this will be result of promise - task = this.updateTaskData(task, payload.data); + this.updateTaskData(task, payload.data); break; case CC_EVENTS.AGENT_CONSULT_ENDED: - task = this.updateTaskData(task, payload.data); + this.updateTaskData(task, payload.data); if (task.data.isConsulted) { // This will be the end state of the task as soon as we end the consult in case of // us being offered a consult @@ -217,30 +219,30 @@ export default class TaskManager extends EventEmitter { break; case CC_EVENTS.AGENT_CTQ_CANCELLED: // This event is received when the consult using queue is cancelled using API - task = this.updateTaskData(task, payload.data); + this.updateTaskData(task, payload.data); task.emit(TASK_EVENTS.TASK_CONSULT_QUEUE_CANCELLED, task); break; case CC_EVENTS.AGENT_WRAPUP: - task = this.updateTaskData(task, payload.data); + this.updateTaskData(task, payload.data); break; case CC_EVENTS.AGENT_WRAPPEDUP: this.removeTaskFromCollection(task); task.emit(TASK_EVENTS.TASK_WRAPPEDUP, task); break; case CC_EVENTS.CONTACT_RECORDING_PAUSED: - task = this.updateTaskData(task, payload.data); + this.updateTaskData(task, payload.data); task.emit(TASK_EVENTS.TASK_RECORDING_PAUSED, task); break; case CC_EVENTS.CONTACT_RECORDING_PAUSE_FAILED: - task = this.updateTaskData(task, payload.data); + this.updateTaskData(task, payload.data); task.emit(TASK_EVENTS.TASK_RECORDING_PAUSE_FAILED, task); break; case CC_EVENTS.CONTACT_RECORDING_RESUMED: - task = this.updateTaskData(task, payload.data); + this.updateTaskData(task, payload.data); task.emit(TASK_EVENTS.TASK_RECORDING_RESUMED, task); break; case CC_EVENTS.CONTACT_RECORDING_RESUME_FAILED: - task = this.updateTaskData(task, payload.data); + this.updateTaskData(task, payload.data); task.emit(TASK_EVENTS.TASK_RECORDING_RESUME_FAILED, task); break; default: @@ -253,47 +255,28 @@ export default class TaskManager extends EventEmitter { }); } - private updateTaskData(task: ITask, taskData: TaskData): ITask { + private updateTaskData(task: ITask, taskData: TaskData) { if (!task) { - return undefined; + throw new Error('Task not found for update'); } - if (!taskData?.interactionId) { - LoggerProxy.warn('Received task update with missing interactionId', { - module: TASK_MANAGER_FILE, - method: 'updateTaskData', - }); - } - - try { - const currentTask = task.updateTaskData(taskData); - this.taskCollection[taskData.interactionId] = currentTask; - - return currentTask; - } catch (error) { - LoggerProxy.error(`Failed to update task ${taskData.interactionId}`, { - module: TASK_MANAGER_FILE, - method: 'updateTaskData', - }); - - return task; - } + task.updateTaskData(taskData); + this.taskCollection[taskData.interactionId] = task; } private removeTaskFromCollection(task: ITask) { - if (task?.data?.interactionId) { - delete this.taskCollection[task.data.interactionId]; - LoggerProxy.info(`Task removed from collection: ${task.data.interactionId}`, { - module: TASK_MANAGER_FILE, - method: 'removeTaskFromCollection', - }); - } + delete this.taskCollection[task.data.interactionId]; + LoggerProxy.info(`Task removed: ${task.data.interactionId}`, { + module: TASK_MANAGER_FILE, + method: 'removeTaskFromCollection', + }); } private handleTaskCleanup(task: ITask) { if ( this.webCallingService.loginOption === LoginOption.BROWSER && - task.data.interaction.mediaType === 'telephony' + task.data.interaction.mediaType === MEDIA_CHANNEL.TELEPHONY && + task instanceof WebRTC ) { task.unregisterWebCallListeners(); this.webCallingService.cleanUpCall(); @@ -305,34 +288,23 @@ export default class TaskManager extends EventEmitter { } } - /** - * @param taskId - Unique identifier for each task - */ - public getTask = (taskId: string) => { + public getTask(taskId: TaskId): ITask { return this.taskCollection[taskId]; - }; + } - /** - * @param taskId - Unique identifier for each task - */ - public getAllTasks = (): Record => { - return this.taskCollection; - }; + public getAllTasks(): Record { + return {...this.taskCollection}; + } - /** - * @param contact - Routing Contact layer. Talks to AQMReq layer to convert events to promises - * @param webCallingService - Webrtc Service Layer - * @param webSocketManager - Websocket Manager to maintain websocket connection and keepalives - */ - public static getTaskManager = ( + public static getTaskManager( contact: ReturnType, webCallingService: WebCallingService, webSocketManager: WebSocketManager - ): TaskManager => { - if (!this.taskManager) { - this.taskManager = new TaskManager(contact, webCallingService, webSocketManager); + ): TaskManager { + if (!TaskManager.taskManager) { + TaskManager.taskManager = new TaskManager(contact, webCallingService, webSocketManager); } - return this.taskManager; - }; + return TaskManager.taskManager; + } } diff --git a/packages/@webex/plugin-cc/src/services/task/digital/Digital.ts b/packages/@webex/plugin-cc/src/services/task/digital/Digital.ts new file mode 100644 index 00000000000..b6ac81d5eac --- /dev/null +++ b/packages/@webex/plugin-cc/src/services/task/digital/Digital.ts @@ -0,0 +1,27 @@ +import {CC_FILE} from '../../../constants'; +import {getErrorDetails} from '../../core/Utils'; +import {IDigital, TaskResponse} from '../types'; +import Task from '../Task'; + +export default class Digital extends Task implements IDigital { + /** + * This is used for incoming digital task accept by agent. + * + * @returns Promise + * @throws Error + * @example + * ```typescript + * task.accept().then(()=>{}).catch(()=>{}) + * ``` + */ + public async accept(): Promise { + try { + return this.contact.accept({interactionId: this.data.interactionId}); + } catch (error) { + const {error: detailedError} = getErrorDetails(error, 'accept', CC_FILE); + throw detailedError; + } + } + + protected setUIControls(): void {} +} diff --git a/packages/@webex/plugin-cc/src/services/task/index.ts b/packages/@webex/plugin-cc/src/services/task/index.ts index d0208432fce..084fc065ffc 100644 --- a/packages/@webex/plugin-cc/src/services/task/index.ts +++ b/packages/@webex/plugin-cc/src/services/task/index.ts @@ -6,7 +6,7 @@ import {LoginOption} from '../../types'; import {CC_FILE} from '../../constants'; import routingContact from './contact'; import { - ITask, + IOldTask, TaskResponse, TaskData, TaskId, @@ -26,7 +26,7 @@ import MetricsManager from '../../metrics/MetricsManager'; import {METRIC_EVENT_NAMES} from '../../metrics/constants'; import {Failure} from '../core/GlobalTypes'; -export default class Task extends EventEmitter implements ITask { +export default class Task extends EventEmitter implements IOldTask { private contact: ReturnType; private localAudioStream: LocalMicrophoneStream; private webCallingService: WebCallingService; diff --git a/packages/@webex/plugin-cc/src/services/task/types.ts b/packages/@webex/plugin-cc/src/services/task/types.ts index 0bbf5322ea9..272d34ae762 100644 --- a/packages/@webex/plugin-cc/src/services/task/types.ts +++ b/packages/@webex/plugin-cc/src/services/task/types.ts @@ -416,10 +416,8 @@ export type ContactCleanupData = { */ export type TaskResponse = AgentContact | Error | void; -/** - * Represents an interface for managing task related operations. - */ -export interface ITask extends EventEmitter { +// TODO: Remove this interface when we have a proper refactor of the Task com,pleted +export interface IOldTask extends EventEmitter { /** * Event data received in the CC events */ @@ -435,7 +433,7 @@ export interface ITask extends EventEmitter { /** * Used to update the task when the data received on each event */ - updateTaskData(newData: TaskData): ITask; + updateTaskData(newData: TaskData): IOldTask; /** * Answers/accepts the incoming task * @@ -511,3 +509,196 @@ export interface ITask extends EventEmitter { */ resumeRecording(resumeRecordingPayload: ResumeRecordingPayload): Promise; } + +/** + * Represents an interface for managing general task operations. + * Extends IOldTask with UI controls and core task actions. + */ +export interface ITask extends EventEmitter { + /** + * Event data received in the CC events + */ + data: TaskData; + /** + * Map of task with call + */ + webCallMap: Record; + /** + * Object which holds the task UI controls (buttons visibility and enabled/disabled state) + */ + taskUiControls: TaskUIControls; + /** + * + * @param newData - TaskData + * @returns void + */ + updateTaskData(newData: TaskData); + /** + * Answers/accepts the incoming task + * + * @example + * ``` + * task.accept(); + * ``` + */ + accept(): Promise; + /** + * This is used to end the task. + * @returns Promise + * @example + * ``` + * task.end(); + * ``` + */ + end(): Promise; + /** + * This is used to transfer the task. + * @param transferPayload + * @returns Promise + * @example + * ``` + * task.transfer(data); + * ``` + */ + transfer(transferPayload: TransferPayLoad): Promise; + /** + * This is used to wrap up the task. + * @param wrapupPayload + * @returns Promise + * @example + * ``` + * task.wrapup(data); + * ``` + */ + wrapup(wrapupPayload: WrapupPayLoad): Promise; +} + +/** + * Represents an interface for voice-specific tasks. + * Extends ITask with methods for hold, resume, and consult. + */ +export interface IVoice extends ITask { + /** + * This is used to hold the task. + * @returns Promise + * @example + * ``` + * task.hold(); + * ``` + */ + hold(): Promise; + /** + * This is used to resume the task. + * @returns Promise + * @example + * ``` + * task.resume(); + * ``` + */ + resume(): Promise; + /** + * This is used to consult the task. + * @param consultPayload + * @returns Promise + * @example + * ``` + * task.consult(data); + * ``` + */ + consult(consultPayload: ConsultPayload): Promise; +} + +/** + * Represents a digital task interface. + * Alias for ITask to indicate digital-only tasks. + */ +export type IDigital = ITask; + +/** + * Represents an interface for WebRTC tasks. + * Extends IVoice with methods for mute, decline, and unregister web call listeners. + */ +export interface IWebRTC extends IVoice { + /** + * This method is used to mute/unmute the call. + * @returns Promise + * @example + * ```typescript + * task.toggleMute(); + * ``` + */ + toggleMute(): Promise; + /** + * Decline the incoming task for Browser Login + * + * @example + * ``` + * task.decline(); + * ``` + */ + decline(): Promise; + /** + * This method is used to unregister the web call listeners. + * @returns void + * @example + * ```typescript + * task.unregisterWebCallListeners(); + * ``` + */ + unregisterWebCallListeners(): void; +} + +/** + * Represents the class which holds the task button controls. + */ +export class TaskButtonControl { + public visible: boolean; + public enabled: boolean; + constructor(visible = false, enabled = true) { + this.visible = visible; + this.enabled = enabled; + } + + /** + * Shows the button control. + */ + show() { + this.visible = true; + } + + /** + * Hides the button control. + */ + hide() { + this.visible = false; + } + + /** + * Enables the button control. + */ + enable() { + this.enabled = true; + } + + /** + * Disables the button control. + */ + disable() { + this.enabled = false; + } +} + +export interface TaskUIControls { + accept: TaskButtonControl; + decline: TaskButtonControl; + hold: TaskButtonControl; + mute: TaskButtonControl; + transfer: TaskButtonControl; + consult: TaskButtonControl; + consultTransfer: TaskButtonControl; + recording: TaskButtonControl; + end: TaskButtonControl; + conference: TaskButtonControl; + endConsult: TaskButtonControl; + wrapup: TaskButtonControl; +} diff --git a/packages/@webex/plugin-cc/src/services/task/voice/Voice.ts b/packages/@webex/plugin-cc/src/services/task/voice/Voice.ts new file mode 100644 index 00000000000..da894b0d2c2 --- /dev/null +++ b/packages/@webex/plugin-cc/src/services/task/voice/Voice.ts @@ -0,0 +1,173 @@ +import {CC_FILE} from '../../../constants'; +import {getErrorDetails} from '../../core/Utils'; +import routingContact from '../contact'; +import {ConsultPayload, ResumeRecordingPayload, TaskResponse, IVoice, TaskData} from '../types'; +import Task from '../Task'; +import LoggerProxy from '../../../logger-proxy'; + +export default class Voice extends Task implements IVoice { + private isEndCallEnabled: boolean; + private isEndConsultEnabled: boolean; + + constructor( + contact: ReturnType, + data: TaskData, + callOptions: {isEndCallEnabled?: boolean; isEndConsultEnabled?: boolean} = {} + ) { + super(contact, data); + // apply defaults when no explicit setting provided + this.isEndCallEnabled = callOptions.isEndCallEnabled ?? true; + this.isEndConsultEnabled = callOptions.isEndConsultEnabled ?? true; + } + + protected setUIControls(): void { + // if profile disables end-call, always hide the end-call button + if (!this.isEndCallEnabled) { + this.taskUiControls.end.hide(); + } + + // if profile disables end-consult, always hide the consult-end button + if (!this.isEndConsultEnabled) { + this.taskUiControls.endConsult.hide(); + } + } + + /** + * This method is used to accept the task. + * It is expected to be overridden by child classes. + * @returns Promise + * @throws Error + */ + public async accept(): Promise { + LoggerProxy.error('Unsupported operation: accept() in Voice object'); + throw new Error('Unsupported operation: accept() in Voice class'); + } + + /** + * This is used to hold the task. + * @returns Promise + * @throws Error + * @example + * ```typescript + * task.hold().then(()=>{}).catch(()=>{}) + * ``` + * */ + public async hold(): Promise { + try { + const response = await this.contact.hold({ + interactionId: this.data.interactionId, + data: {mediaResourceId: this.data.mediaResourceId}, + }); + + return response; + } catch (error) { + const {error: detailedError} = getErrorDetails(error, 'hold', CC_FILE); + throw detailedError; + } + } + + /** + * This is used to resume the task. + * @returns Promise + * @throws Error + * @example + * ```typescript + * task.resume().then(()=>{}).catch(()=>{}) + * ``` + */ + public async resume(): Promise { + try { + const {mainInteractionId} = this.data.interaction; + const {mediaResourceId} = this.data.interaction.media[mainInteractionId]; + + const response = await this.contact.unHold({ + interactionId: this.data.interactionId, + data: {mediaResourceId}, + }); + + return response; + } catch (error) { + const {error: detailedError} = getErrorDetails(error, 'resume', CC_FILE); + throw detailedError; + } + } + + /** + * This is used to pause the call recording + * @returns Promise + * @throws Error + * @example + * ```typescript + * task.pauseRecording().then(()=>{}).catch(()=>{}); + * ``` + */ + public async pauseRecording(): Promise { + try { + const result = await this.contact.pauseRecording({interactionId: this.data.interactionId}); + + return result; + } catch (error) { + const {error: detailedError} = getErrorDetails(error, 'pauseRecording', CC_FILE); + + throw detailedError; + } + } + + /** + * This is used to pause the call recording + * @param resumeRecordingPayload + * @returns Promise + * @throws Error + * @example + * ```typescript + * task.resumeRecording(resumeRecordingPayload).then(()=>{}).catch(()=>{}); + * ``` + */ + public async resumeRecording( + resumeRecordingPayload: ResumeRecordingPayload + ): Promise { + try { + resumeRecordingPayload ??= {autoResumed: false}; + + const result = await this.contact.resumeRecording({ + interactionId: this.data.interactionId, + data: resumeRecordingPayload, + }); + + return result; + } catch (error) { + const {error: detailedError} = getErrorDetails(error, 'resumeRecording', CC_FILE); + + throw detailedError; + } + } + + /** + * This is used to consult the task + * @param consultPayload + * @returns Promise + * @throws Error + * @example + * ```typescript + * const consultPayload = { + * destination: 'myBuddyAgentId', + * destinationType: DESTINATION_TYPE.AGENT, + * } + * task.consult(consultPayload).then(()=>{}).catch(()=>{}); + * ``` + * */ + public async consult(consultPayload: ConsultPayload): Promise { + try { + const result = await this.contact.consult({ + interactionId: this.data.interactionId, + data: consultPayload, + }); + + return result; + } catch (error) { + const {error: detailedError} = getErrorDetails(error, 'consult', CC_FILE); + + throw detailedError; + } + } +} diff --git a/packages/@webex/plugin-cc/src/services/task/voice/WebRTC.ts b/packages/@webex/plugin-cc/src/services/task/voice/WebRTC.ts new file mode 100644 index 00000000000..6929726184e --- /dev/null +++ b/packages/@webex/plugin-cc/src/services/task/voice/WebRTC.ts @@ -0,0 +1,186 @@ +import {LocalMicrophoneStream, CALL_EVENT_KEYS} from '@webex/calling'; +import {CC_FILE} from '../../../constants'; +import {getErrorDetails} from '../../core/Utils'; +import routingContact from '../contact'; +import {TaskData, TaskResponse, TASK_EVENTS, IWebRTC} from '../types'; +import Voice from './Voice'; +import WebCallingService from '../../WebCallingService'; +import {CC_EVENTS} from '../../config/types'; +import MetricsManager from '../../../metrics/MetricsManager'; +import {METRIC_EVENT_NAMES} from '../../../metrics/constants'; +import LoggerProxy from '../../../logger-proxy'; + +export default class WebRTC extends Voice implements IWebRTC { + private localAudioStream: LocalMicrophoneStream; + private webCallingService: WebCallingService; + + constructor( + contact: ReturnType, + webCallingService: WebCallingService, + data: TaskData, + callOptions: {isEndCallEnabled?: boolean; isEndConsultEnabled?: boolean} = {} + ) { + super(contact, data, callOptions); + this.webCallingService = webCallingService; + } + + private registerWebCallListeners() { + this.webCallingService.on(CALL_EVENT_KEYS.REMOTE_MEDIA, this.handleRemoteMedia); + } + + private handleRemoteMedia = (track: MediaStreamTrack) => { + this.emit(TASK_EVENTS.TASK_MEDIA, track); + }; + + /** + * This method is used to set the UI controls for the specific type of task + */ + protected setUIControls(): void { + // TODO: This implementation will change based on the type of task. We need to modify it appropriately, we can even read from task data rather than listening to events + switch (this.data.type) { + case CC_EVENTS.AGENT_CONTACT_RESERVED: + this.taskUiControls.accept.enable(); + break; + default: + break; + } + } + + /** + * This method is used to unregister the web call listeners. + * @returns void + * @example + * ```typescript + * task.unregisterWebCallListeners(); + * ``` + */ + public unregisterWebCallListeners() { + this.webCallingService.off(CALL_EVENT_KEYS.REMOTE_MEDIA, this.handleRemoteMedia); + } + + /** + * This is used for incoming task accept by agent. + * + * @returns Promise + * @throws Error + * @example + * ```typescript + * task.accept().then(()=>{}).catch(()=>{}) + * ``` + */ + public async accept(): Promise { + LoggerProxy.log(`Accepting WebRTC task for taskId:${this.data.interactionId}`, { + module: 'WebRTC', + method: 'accept', + }); + try { + this.metricsManager.timeEvent([ + METRIC_EVENT_NAMES.TASK_ACCEPT_SUCCESS, + METRIC_EVENT_NAMES.TASK_ACCEPT_FAILED, + ]); + + const constraints = {audio: true}; + const localStream = await navigator.mediaDevices.getUserMedia(constraints); + const audioTrack = localStream.getAudioTracks()[0]; + this.localAudioStream = new LocalMicrophoneStream(new MediaStream([audioTrack])); + this.webCallingService.answerCall(this.localAudioStream, this.data.interactionId); + + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.TASK_ACCEPT_SUCCESS, + { + taskId: this.data.interactionId, + ...MetricsManager.getCommonTrackingFieldForAQMResponse(this.data), + }, + ['operational', 'behavioral', 'business'] + ); + + return Promise.resolve(); + } catch (error) { + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.TASK_ACCEPT_FAILED, + { + taskId: this.data.interactionId, + error: error.toString(), + ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed( + (error as any).details || {} + ), + }, + ['operational', 'behavioral', 'business'] + ); + const {error: detailedError} = getErrorDetails(error, 'accept', CC_FILE); + throw detailedError; + } + } + + /** + * This is used for the incoming task decline by agent. + * + * @returns Promise + * @throws Error + * @example + * ```typescript + * task.decline().then(()=>{}).catch(()=>{}) + * ``` + */ + public async decline(): Promise { + LoggerProxy.log(`Declining WebRTC task for taskId:${this.data.interactionId}`, { + module: 'WebRTC', + method: 'decline', + }); + try { + this.metricsManager.timeEvent([ + METRIC_EVENT_NAMES.TASK_DECLINE_SUCCESS, + METRIC_EVENT_NAMES.TASK_DECLINE_FAILED, + ]); + + this.webCallingService.declineCall(this.data.interactionId); + this.unregisterWebCallListeners(); + + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.TASK_DECLINE_SUCCESS, + {taskId: this.data.interactionId}, + ['operational', 'behavioral'] + ); + + return Promise.resolve(); + } catch (error) { + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.TASK_DECLINE_FAILED, + { + taskId: this.data.interactionId, + error: error.toString(), + ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed( + (error as any).details || {} + ), + }, + ['operational', 'behavioral'] + ); + const {error: detailedError} = getErrorDetails(error, 'decline', CC_FILE); + throw detailedError; + } + } + + /** + * This is used for the placing the call in mute or unmute by the agent. + * + * @throws Error + * @example + * ```typescript + * task.toggleMute().then(()=>{}).catch(()=>{}) + * ``` + */ + public async toggleMute() { + LoggerProxy.log(`Toggling mute WebRTC task for taskId:${this.data.interactionId}`, { + module: 'WebRTC', + method: 'toggleMute', + }); + try { + this.webCallingService.muteUnmuteCall(this.localAudioStream); + + return Promise.resolve(); + } catch (error) { + const {error: detailedError} = getErrorDetails(error, 'mute', CC_FILE); + throw detailedError; + } + } +} diff --git a/packages/@webex/plugin-cc/src/types.ts b/packages/@webex/plugin-cc/src/types.ts index d814f52b85d..ea6fb7c644e 100644 --- a/packages/@webex/plugin-cc/src/types.ts +++ b/packages/@webex/plugin-cc/src/types.ts @@ -295,6 +295,18 @@ export interface GenericError extends Error { }; } +/** + * Holds the configuration flags for the Agent. + * These flags determine the availability of certain features in the Agent UI. + * @internal + */ +export type ConfigFlags = { + isEndCallEnabled: boolean; + isEndConsultEnabled: boolean; + webRtcEnabled: boolean; + autoWrapup: boolean; +}; + export type StationLoginResponse = Agent.StationLoginSuccessResponse | Error; export type StationLogoutResponse = Agent.LogoutSuccess | Error; export type StationReLoginResponse = Agent.ReloginSuccess | Error; diff --git a/packages/@webex/plugin-cc/test/unit/spec/cc.ts b/packages/@webex/plugin-cc/test/unit/spec/cc.ts index a59117c2373..d177d80333c 100644 --- a/packages/@webex/plugin-cc/test/unit/spec/cc.ts +++ b/packages/@webex/plugin-cc/test/unit/spec/cc.ts @@ -141,6 +141,7 @@ describe('webex.cc', () => { off: jest.fn(), emit: jest.fn(), unregisterIncomingCallEvent: jest.fn(), + setConfigFlags: jest.fn(), }; mockMetricsManager = { @@ -301,6 +302,12 @@ describe('webex.cc', () => { module: CC_FILE, method: 'mockConstructor', }); + expect(mockTaskManager.setConfigFlags).toHaveBeenCalledWith({ + isEndCallEnabled: mockAgentProfile.isEndCallEnabled, + isEndConsultEnabled: mockAgentProfile.isEndConsultEnabled, + webRtcEnabled: mockAgentProfile.webRtcEnabled, + autoWrapup: mockAgentProfile.wrapUpData.wrapUpProps.autoWrapup ?? false, + }); expect(reloadSpy).toHaveBeenCalled(); expect(result).toEqual(mockAgentProfile); expect(mockMetricsManager.timeEvent).toHaveBeenCalledWith([ diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/task/Task.ts b/packages/@webex/plugin-cc/test/unit/spec/services/task/Task.ts new file mode 100644 index 00000000000..454e05b04f2 --- /dev/null +++ b/packages/@webex/plugin-cc/test/unit/spec/services/task/Task.ts @@ -0,0 +1,203 @@ +import Task from '../../../../../src/services/task/Task'; +import {TaskData, DESTINATION_TYPE} from '../../../../../src/services/task/types'; + +class DummyTask extends Task { + public accept() { return Promise.resolve({} as any); } +} + +jest.mock('../../../../../src/logger-proxy', () => ({ + __esModule: true, + default: { + log: jest.fn(), + error: jest.fn(), + info: jest.fn(), + initialize: jest.fn(), + }, +})); + +jest.mock('../../../../../src/services/core/WebexRequest', () => ({ + __esModule: true, + default: { + getInstance: jest.fn().mockReturnValue({ uploadLogs: jest.fn() }), + }, +})); + +describe('Task (base class)', () => { + const dummyContact = {} as any; + const initialData = { + foo: 'bar', + nested: {a: 1, b: 2}, + } as unknown as TaskData; + + let task: DummyTask; + + beforeEach(() => { + task = new DummyTask(dummyContact, initialData); + }); + + it('merges updateTaskData when shouldOverwrite is false', () => { + const updated = {foo: 'baz', nested: {b: 3}} as unknown as TaskData; + task.updateTaskData(updated); + expect(task.data.foo).toBe('baz'); + // nested.a remains, nested.b updated + expect((task.data as any).nested).toEqual({a: 1, b: 3}); + }); + + it('overwrites data when shouldOverwrite is true', () => { + const updated = {x: 42} as unknown as TaskData; + task.updateTaskData(updated, true); + expect((task.data as any).x).toBe(42); + expect((task.data as any).foo).toBeUndefined(); + }); + + it('getUIControls returns default controls shape', () => { + const controls = task.taskUiControls; + // accept & decline, end & transfer should be visible/enabled + expect(controls.accept.visible).toBe(true); + expect(controls.accept.enabled).toBe(true); + expect(controls.decline.visible).toBe(true); + expect(controls.decline.enabled).toBe(true); + expect(controls.end.visible).toBe(true); + expect(controls.end.enabled).toBe(true); + expect(controls.transfer.visible).toBe(true); + expect(controls.transfer.enabled).toBe(true); + + // all other controls should be hidden/disabled + expect(controls.hold.visible).toBe(false); + expect(controls.hold.enabled).toBe(false); + expect(controls.mute.visible).toBe(false); + expect(controls.mute.enabled).toBe(false); + expect(controls.consult.visible).toBe(false); + expect(controls.consult.enabled).toBe(false); + expect(controls.consultTransfer.visible).toBe(false); + expect(controls.consultTransfer.enabled).toBe(false); + expect(controls.endConsult.visible).toBe(false); + expect(controls.endConsult.enabled).toBe(false); + expect(controls.recording.visible).toBe(false); + expect(controls.recording.enabled).toBe(false); + expect(controls.conference.visible).toBe(false); + expect(controls.conference.enabled).toBe(false); + expect(controls.wrapup.visible).toBe(false); + expect(controls.wrapup.enabled).toBe(false); + }); + + it('calls setUIControls when updateTaskData is invoked', () => { + const spy = jest.spyOn(task as any, 'setUIControls'); + task.updateTaskData({foo: 'new'} as TaskData); + expect(spy).toHaveBeenCalled(); + }); + +}); + +describe('Task common methods', () => { + let contact: any; + let task: DummyTask; + const taskData = {interactionId: '123', foo: 'bar', nested: {a: 1, b: 2}} as TaskData; + + beforeEach(() => { + contact = { + vteamTransfer: jest.fn().mockResolvedValue({result: 'vt'}), + blindTransfer: jest.fn().mockResolvedValue({result: 'bt'}), + end: jest.fn().mockResolvedValue({result: 'end'}), + wrapup: jest.fn().mockResolvedValue({result: 'wrap'}), + }; + task = new DummyTask(contact, taskData); + }); + + it('transfer uses blindTransfer for agent destinations', async () => { + const payload = {to: 'dest', destinationType: DESTINATION_TYPE.AGENT} as any; + const result = await task.transfer(payload); + expect(contact.blindTransfer).toHaveBeenCalledWith({ + interactionId: taskData.interactionId, + data: payload, + }); + expect(result).toEqual({result: 'bt'}); + }); + + it('transfer uses vteamTransfer for queue destinations', async () => { + const payload = {to: 'queue1', destinationType: DESTINATION_TYPE.QUEUE} as any; + const result = await task.transfer(payload); + expect(contact.vteamTransfer).toHaveBeenCalledWith({ + interactionId: taskData.interactionId, + data: payload, + }); + expect(result).toEqual({result: 'vt'}); + }); + + it('end invokes contact.end and returns its response', async () => { + const result = await task.end(); + expect(contact.end).toHaveBeenCalledWith({ + interactionId: taskData.interactionId, + }); + expect(result).toEqual({result: 'end'}); + }); + + it('wrapup invokes contact.wrapup with proper args', async () => { + const payload = {auxCodeId: 'code1', wrapUpReason: 'reason1'} as any; + const result = await task.wrapup(payload); + expect(contact.wrapup).toHaveBeenCalledWith({ + interactionId: taskData.interactionId, + data: payload, + }); + expect(result).toEqual({result: 'wrap'}); + }); +}); + +describe('Task failure scenarios', () => { + let contact: any; + let task: DummyTask; + const taskData = {interactionId: '123', foo: 'bar', nested: {a: 1, b: 2}} as TaskData; + + beforeEach(() => { + contact = { + vteamTransfer: jest.fn(), + blindTransfer: jest.fn(), + end: jest.fn(), + wrapup: jest.fn(), + }; + task = new DummyTask(contact, taskData); + }); + + it('transfer rejects when blindTransfer throws', async () => { + const payload = {to: 'dest', destinationType: DESTINATION_TYPE.AGENT} as any; + const err = new Error('Error while performing transfer'); + contact.blindTransfer.mockRejectedValue(err); + + await expect(task.transfer(payload)) + .rejects + .toThrow('Error while performing transfer'); + }); + + it('transfer rejects when vteamTransfer throws', async () => { + const payload = {to: 'queue1', destinationType: DESTINATION_TYPE.QUEUE} as any; + const err = new Error('Error while performing transfer'); + contact.vteamTransfer.mockRejectedValue(err); + + await expect(task.transfer(payload)) + .rejects + .toThrow('Error while performing transfer'); + }); + + it('end rejects when contact.end throws', async () => { + const err = new Error('Error while performing end'); + contact.end.mockRejectedValue(err); + + await expect(task.end()).rejects.toThrow('Error while performing end'); + }); + + it('wrapup throws when auxCodeId is missing', async () => { + await expect(task.wrapup({auxCodeId: '', wrapUpReason: 'reason1'} as any)).rejects.toThrow('Error while performing wrapup'); + }); + + it('wrapup throws when wrapUpReason is missing', async () => { + await expect(task.wrapup({auxCodeId: 'code1', wrapUpReason: ''} as any)).rejects.toThrow('Error while performing wrapup'); + }); + + it('wrapup rejects when contact.wrapup throws', async () => { + const payload = {auxCodeId: 'code1', wrapUpReason: 'reason1'} as any; + const err = new Error('Error while performing wrapup'); + contact.wrapup.mockRejectedValue(err); + + await expect(task.wrapup(payload)).rejects.toThrow('Error while performing wrapup'); + }); +}); \ No newline at end of file diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/task/TaskFactory.ts b/packages/@webex/plugin-cc/test/unit/spec/services/task/TaskFactory.ts new file mode 100644 index 00000000000..61e1b0f8fc6 --- /dev/null +++ b/packages/@webex/plugin-cc/test/unit/spec/services/task/TaskFactory.ts @@ -0,0 +1,57 @@ +import 'jsdom-global/register'; +import TaskFactory from '../../../../../src/services/task/TaskFactory'; +import {MEDIA_CHANNEL, TaskData} from '../../../../../src/services/task/types'; +import {LoginOption} from '../../../../../src/types'; +import WebCallingService from '../../../../../src/services/WebCallingService'; +import {ConfigFlags} from '../../../../../src/types'; + +describe('TaskFactory', () => { + const dummyContact = {} as any; + const baseData: Partial = { + interactionId: 'id', + interaction: {mediaType: MEDIA_CHANNEL.TELEPHONY}, + }; + + const makeSvc = (loginOption: LoginOption) => + ({loginOption} as unknown) as WebCallingService; + + const configFlags: ConfigFlags = { + isEndCallEnabled: true, + isEndConsultEnabled: true, + webRtcEnabled: true, + autoWrapup: false, + }; + + it('creates WebRTC for TELEPHONY + BROWSER', () => { + const svc = makeSvc(LoginOption.BROWSER); + const task = TaskFactory.createTask(dummyContact, svc, baseData as TaskData, configFlags); + expect(task.constructor.name).toBe('WebRTC'); + }); + + it('creates Voice for TELEPHONY + EXTENSION', () => { + const svc = makeSvc(LoginOption.EXTENSION); + const task = TaskFactory.createTask(dummyContact, svc, baseData as TaskData, configFlags); + expect(task.constructor.name).toBe('Voice'); + }); + + it('creates Digital for CHAT, EMAIL, SOCIAL', () => { + const svc = makeSvc(LoginOption.BROWSER); + for (const type of [MEDIA_CHANNEL.CHAT, MEDIA_CHANNEL.EMAIL, MEDIA_CHANNEL.SOCIAL]) { + const data = {...baseData, interaction: {mediaType: type}} as TaskData; + const task = TaskFactory.createTask(dummyContact, svc, data, configFlags); + expect(task.constructor.name).toBe('Digital'); + } + }); + + it('defaults undefined mediaType to TELEPHONY', () => { + const svcBrowser = makeSvc(LoginOption.BROWSER); + const svcExt = makeSvc(LoginOption.EXTENSION); + const data = {interactionId: 'id', interaction: {}} as TaskData; + + const t1 = TaskFactory.createTask(dummyContact, svcBrowser, data, configFlags); + expect(t1.constructor.name).toBe('WebRTC'); + + const t2 = TaskFactory.createTask(dummyContact, svcExt, data, configFlags); + expect(t2.constructor.name).toBe('Voice'); + }); +}); diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/task/TaskManager.ts b/packages/@webex/plugin-cc/test/unit/spec/services/task/TaskManager.ts index 512623a3b93..ace4bcb673b 100644 --- a/packages/@webex/plugin-cc/test/unit/spec/services/task/TaskManager.ts +++ b/packages/@webex/plugin-cc/test/unit/spec/services/task/TaskManager.ts @@ -5,11 +5,13 @@ import {CALL_EVENT_KEYS, CallingClientConfig, LINE_EVENTS} from '@webex/calling' import {CC_AGENT_EVENTS, CC_EVENTS} from '../../../../../src/services/config/types'; import TaskManager from '../../../../../src/services/task/TaskManager'; import * as contact from '../../../../../src/services/task/contact'; -import Task from '../../../../../src/services/task'; import {TASK_EVENTS} from '../../../../../src/services/task/types'; +import WebRTC from '../../../../../src/services/task/voice/WebRTC'; +import {Profile} from '../../../../../src/services/config/types'; import WebCallingService from '../../../../../src/services/WebCallingService'; import config from '../../../../../src/config'; import {CC_TASK_EVENTS} from '../../../../../src/services/config/types'; +import TaskFactory from '../../../../../src/services/task/TaskFactory'; describe('TaskManager', () => { let mockCall; @@ -75,6 +77,8 @@ describe('TaskManager', () => { offSpy = jest.spyOn(webCallingService, 'off'); taskManager = new TaskManager(contactMock, webCallingService, webSocketManagerMock); + taskManager.setConfigFlags({} as Profile); + taskManager.taskCollection[taskId] = { emit: jest.fn(), accept: jest.fn(), @@ -83,6 +87,22 @@ describe('TaskManager', () => { data: taskDataMock, }; taskManager.call = mockCall; + + jest.spyOn(TaskFactory, 'createTask').mockImplementation((contact, webCallingService, data, configFlags) => { + const task: any = { + emit: jest.fn(), + accept: jest.fn(), + decline: jest.fn(), + updateTaskData: jest.fn().mockImplementation((newData) => { + task.data = {...task.data, ...newData}; + return task; + }), + unregisterWebCallListeners: jest.fn(), + data, + }; + + return task; + }); }); afterEach(() => { @@ -307,45 +327,67 @@ describe('TaskManager', () => { expect(allTasks).toHaveProperty(taskId2, mockTask2); }); - it('test call listeners being switched off on call end', () => { + it('test call listeners being switched off on call end for webRTC task', () => { webSocketManagerMock.emit('message', JSON.stringify(initalPayload)); - const taskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit'); - const webCallListenerSpy = jest.spyOn(taskManager.getTask(taskId), 'unregisterWebCallListeners'); + const webrtcTask = new WebRTC( + contactMock, + webCallingService, + taskDataMock, + {isEndCallEnabled: true, isEndConsultEnabled: true} + ); + (taskManager as any).taskCollection[taskId] = webrtcTask; + + const task = taskManager.getTask(taskId)!; + const originalEmit = task.emit; + const taskEmitSpy = jest.spyOn(task, 'emit').mockImplementation((event, arg) => { + if (event === CC_EVENTS.CONTACT_ENDED) { + return; + } + return originalEmit.call(task, event, arg); + }); + + const webCallListenerSpy = jest.spyOn(task, 'unregisterWebCallListeners'); const callOffSpy = jest.spyOn(mockCall, 'off'); + const payload = { data: { type: CC_EVENTS.CONTACT_ENDED, - agentId: '723a8ffb-a26e-496d-b14a-ff44fb83b64f', - eventTime: 1733211616959, - eventType: 'RoutingMessage', + agentId: taskDataMock.agentId, + eventTime: taskDataMock.eventTime, + eventType: taskDataMock.eventType, interaction: {state: 'new', mediaType: 'telephony'}, interactionId: taskId, - orgId: '6ecef209-9a34-4ed1-a07a-7ddd1dbe925a', - trackingId: '575c0ec2-618c-42af-a61c-53aeb0a221ee', - mediaResourceId: '0ae913a4-c857-4705-8d49-76dd3dde75e4', - destAgentId: 'ebeb893b-ba67-4f36-8418-95c7492b28c2', - owner: '723a8ffb-a26e-496d-b14a-ff44fb83b64f', - queueMgr: 'aqm', + orgId: taskDataMock.orgId, + trackingId: taskDataMock.trackingId, + mediaResourceId: taskDataMock.mediaResourceId, + destAgentId: taskDataMock.destAgentId, + owner: taskDataMock.owner, + queueMgr: taskDataMock.queueMgr, }, }; - taskManager.getTask(taskId).data = payload.data; - const task = taskManager.getTask(taskId) + task.data = payload.data; webSocketManagerMock.emit('message', JSON.stringify(payload)); + + expect(taskEmitSpy).toHaveBeenCalledTimes(2); expect(taskEmitSpy).toHaveBeenCalledWith( - TASK_EVENTS.TASK_END, task + TASK_EVENTS.TASK_END, + task ); - expect(webCallListenerSpy).toHaveBeenCalledWith(); + + expect(webCallListenerSpy).toHaveBeenCalled(); expect(callOffSpy).toHaveBeenCalledWith( CALL_EVENT_KEYS.REMOTE_MEDIA, callOffSpy.mock.calls[0][1] ); taskManager.unregisterIncomingCallEvent(); - expect(offSpy.mock.calls.length).toBe(2); // 1 for incoming call and 1 for remote media - expect(offSpy).toHaveBeenCalledWith(CALL_EVENT_KEYS.REMOTE_MEDIA, offSpy.mock.calls[0][1]); - expect(offSpy).toHaveBeenCalledWith(LINE_EVENTS.INCOMING_CALL, offSpy.mock.calls[1][1]); + expect(offSpy).toHaveBeenCalledTimes(2); + expect(offSpy).toHaveBeenCalledWith( + LINE_EVENTS.INCOMING_CALL, + offSpy.mock.calls[1][1] + ); }); it('should emit TASK_END event with wrapupRequired on regular call end', () => { @@ -801,25 +843,30 @@ describe('TaskManager', () => { it('should handle default case', () => { webSocketManagerMock.emit('message', JSON.stringify(initalPayload)); + const task = taskManager.getTask(taskId)!; + task.emit.mockClear(); + task.updateTaskData.mockClear(); + const payload = { data: { type: 'UNKNOWN_EVENT', - agentId: '723a8ffb-a26e-496d-b14a-ff44fb83b64f', - eventTime: 1733211616959, - eventType: 'RoutingMessage', + agentId: taskDataMock.agentId, + eventTime: taskDataMock.eventTime, + eventType: taskDataMock.eventType, interaction: {}, interactionId: taskId, - orgId: '6ecef209-9a34-4ed1-a07a-7ddd1dbe925a', - trackingId: '575c0ec2-618c-42af-a61c-53aeb0a221ee', - mediaResourceId: '0ae913a4-c857-4705-8d49-76dd3dde75e4', - destAgentId: 'ebeb893b-ba67-4f36-8418-95c7492b28c2', - owner: '723a8ffb-a26e-496d-b14a-ff44fb83b64f', - queueMgr: 'aqm', + orgId: taskDataMock.orgId, + trackingId: taskDataMock.trackingId, + mediaResourceId: taskDataMock.mediaResourceId, + destAgentId: taskDataMock.destAgentId, + owner: taskDataMock.owner, + queueMgr: taskDataMock.queueMgr, }, }; - const taskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit'); - const taskUpdateTaskDataSpy = jest.spyOn(taskManager.getTask(taskId), 'updateTaskData'); + const taskEmitSpy = jest.spyOn(task, 'emit'); + const taskUpdateTaskDataSpy = jest.spyOn(task, 'updateTaskData'); + webSocketManagerMock.emit('message', JSON.stringify(payload)); expect(taskEmitSpy).not.toHaveBeenCalled(); expect(taskUpdateTaskDataSpy).not.toHaveBeenCalled(); diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/task/digital/Digital.ts b/packages/@webex/plugin-cc/test/unit/spec/services/task/digital/Digital.ts new file mode 100644 index 00000000000..7e1f6897fbd --- /dev/null +++ b/packages/@webex/plugin-cc/test/unit/spec/services/task/digital/Digital.ts @@ -0,0 +1,31 @@ +import Digital from '../../../../../../src/services/task/digital/Digital'; +import { TaskResponse } from '../../../../../../src/services/task/types'; + +describe('Digital Task', () => { + const dummyData = { interactionId: 'dig1' } as any; + let dummyContact: { accept: jest.Mock> }; + + beforeEach(() => { + dummyContact = { accept: jest.fn().mockResolvedValue({ status: 'ok' } as any) }; + }); + + it('accept() calls contact.accept with interactionId', async () => { + const task = new Digital(dummyContact as any, dummyData); + const res = await task.accept(); + expect(dummyContact.accept).toHaveBeenCalledWith({ interactionId: 'dig1' }); + expect(res).toEqual({ status: 'ok' }); + }); + + it('accept() throws when contact.accept rejects', async () => { + const error = new Error('fail'); + dummyContact.accept.mockRejectedValue(error); + const task = new Digital(dummyContact as any, dummyData); + await expect(task.accept()).rejects.toThrow('fail'); + }); + + it('default UI controls remain unchanged', () => { + const task = new Digital(dummyContact as any, dummyData); + expect(task.taskUiControls.accept.visible).toBe(true); + expect(task.taskUiControls.decline.visible).toBe(true); + }); +}); diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/task/voice/Voice.ts b/packages/@webex/plugin-cc/test/unit/spec/services/task/voice/Voice.ts new file mode 100644 index 00000000000..4ac62767ccc --- /dev/null +++ b/packages/@webex/plugin-cc/test/unit/spec/services/task/voice/Voice.ts @@ -0,0 +1,104 @@ +import Voice from '../../../../../../src/services/task/voice/Voice'; +import { TaskData } from '../../../../../../src/services/task/types'; + +describe('Voice Task', () => { + const dummyContact = { + hold: jest.fn().mockResolvedValue('held'), + unHold: jest.fn().mockResolvedValue('resumed'), + pauseRecording: jest.fn().mockResolvedValue('paused'), + resumeRecording: jest.fn().mockResolvedValue('resumedRecording'), + consult: jest.fn().mockResolvedValue('consulted'), + } as any; + + const baseData = { + interactionId: 'int1', + mediaResourceId: 'media1', + interaction: { + mainInteractionId: 'main1', + media: { main1: { mediaResourceId: 'media1' } }, + }, + } as unknown as TaskData; + + it('hides end and endConsult when disabled', () => { + const voice = new Voice(dummyContact, baseData, { + isEndCallEnabled: false, + isEndConsultEnabled: false, + }); + voice.updateTaskData(baseData); + expect(voice.taskUiControls.end.visible).toBe(false); + expect(voice.taskUiControls.endConsult.visible).toBe(false); + }); + + it('does not override end and endConsult when enabled', () => { + const voice = new Voice(dummyContact, baseData, { + isEndCallEnabled: true, + isEndConsultEnabled: true, + }); + voice.updateTaskData(baseData); + expect(voice.taskUiControls.end.visible).toBe(true); + expect(voice.taskUiControls.endConsult.visible).toBe(false); // By default it is not visible + }); + + it('hold() calls contact.hold with correct params', async () => { + const voice = new Voice(dummyContact, baseData, { + isEndCallEnabled: true, + isEndConsultEnabled: true, + }); + const res = await voice.hold(); + expect(dummyContact.hold).toHaveBeenCalledWith({ + interactionId: 'int1', + data: { mediaResourceId: 'media1' }, + }); + expect(res).toBe('held'); + }); + + it('resume() calls contact.unHold with correct mediaResourceId', async () => { + const voice = new Voice(dummyContact, baseData, { + isEndCallEnabled: true, + isEndConsultEnabled: true, + }); + const res = await voice.resume(); + expect(dummyContact.unHold).toHaveBeenCalledWith({ + interactionId: 'int1', + data: { mediaResourceId: 'media1' }, + }); + expect(res).toBe('resumed'); + }); + + it('pauseRecording() calls contact.pauseRecording', async () => { + const voice = new Voice(dummyContact, baseData, { + isEndCallEnabled: true, + isEndConsultEnabled: true, + }); + const res = await voice.pauseRecording(); + expect(dummyContact.pauseRecording).toHaveBeenCalledWith({ interactionId: 'int1' }); + expect(res).toBe('paused'); + }); + + it('resumeRecording() with no payload defaults to autoResumed false', async () => { + const voice = new Voice(dummyContact, baseData, { + isEndCallEnabled: true, + isEndConsultEnabled: true, + }); + const res = await voice.resumeRecording(); + expect(dummyContact.resumeRecording).toHaveBeenCalledWith({ + interactionId: 'int1', + data: { autoResumed: false }, + }); + expect(res).toBe('resumedRecording'); + }); + + it('consult() calls contact.consult with payload', async () => { + const voice = new Voice(dummyContact, baseData, { + isEndCallEnabled: true, + isEndConsultEnabled: true, + }); + const payload = { destination: 'agent1', destinationType: 'agent' } as any; + const res = await voice.consult(payload); + expect(dummyContact.consult).toHaveBeenCalledWith({ + interactionId: 'int1', + data: payload, + }); + expect(res).toBe('consulted'); + }); +}); diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/task/voice/WebRTC.ts b/packages/@webex/plugin-cc/test/unit/spec/services/task/voice/WebRTC.ts new file mode 100644 index 00000000000..f303aaa17d7 --- /dev/null +++ b/packages/@webex/plugin-cc/test/unit/spec/services/task/voice/WebRTC.ts @@ -0,0 +1,76 @@ +import 'jsdom-global/register'; +import { LocalMicrophoneStream } from '@webex/calling'; +import WebRTC from '../../../../../../src/services/task/voice/WebRTC'; +import { TaskData } from '../../../../../../src/services/task/types'; + +jest.mock('@webex/calling', () => ({ + LocalMicrophoneStream: class { + constructor(stream: any) { this.outputStream = stream; } + }, + CALL_EVENT_KEYS: { REMOTE_MEDIA: 'remoteMedia' }, +})); + +beforeAll(() => { + navigator.mediaDevices = { getUserMedia: jest.fn() }; + // @ts-ignore + global.MediaStream = class { + constructor(private tracks: any[]) {} + getAudioTracks() { return this.tracks; } + }; +}); + +jest.mock('../../../../../../src/services/core/WebexRequest', () => ({ + __esModule: true, + default: { + getInstance: jest.fn().mockReturnValue({ uploadLogs: jest.fn() }), + }, +})); + +describe('WebRTC Task', () => { + const dummyContact = {} as any; + const data = { interactionId: 'int1', type: 'dummyType' } as unknown as TaskData; + const webCallingService = { + on: jest.fn(), + off: jest.fn(), + answerCall: jest.fn(), + declineCall: jest.fn(), + muteUnmuteCall: jest.fn(), + }; + + let webRtc: WebRTC; + + beforeEach(() => { + webRtc = new WebRTC( + dummyContact, + webCallingService as any, + data, + { isEndCallEnabled: true, isEndConsultEnabled: true } + ); + }); + + it('accept() obtains media and answers call', async () => { + const fakeTrack = {} as any; + const fakeStream = { getAudioTracks: () => [fakeTrack] } as any; + jest.spyOn(navigator.mediaDevices, 'getUserMedia').mockResolvedValue(fakeStream); + await webRtc.accept(); + expect(webCallingService.answerCall).toHaveBeenCalled(); + const [[streamArg, interactionIdArg]] = webCallingService.answerCall.mock.calls; + expect(streamArg).toBeInstanceOf(LocalMicrophoneStream); + expect(interactionIdArg).toBe('int1'); + }); + + it('decline() calls declineCall and unregisters listeners', async () => { + jest.spyOn(webRtc as any, 'unregisterWebCallListeners'); + const res = await webRtc.decline(); + expect(webCallingService.declineCall).toHaveBeenCalledWith('int1'); + expect((webRtc as any).unregisterWebCallListeners).toHaveBeenCalled(); + expect(res).toBeUndefined(); + }); + + it('toggleMute() calls muteUnmuteCall with stored localAudioStream', async () => { + const dummyStream = {} as any; + (webRtc as any).localAudioStream = dummyStream; + await webRtc.toggleMute(); + expect(webCallingService.muteUnmuteCall).toHaveBeenCalledWith(dummyStream); + }); +}); From f0af195a4597d2c60ba1ce828b7614f030b2fec9 Mon Sep 17 00:00:00 2001 From: Adhwaith Menon <111346225+adhmenon@users.noreply.github.com> Date: Fri, 6 Jun 2025 12:53:12 +0530 Subject: [PATCH 3/8] feat(plugin-cc): merge-next-branch-into-task-refactor (#4325) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: robstax Co-authored-by: rstachof Co-authored-by: Coread Co-authored-by: László Vadász Co-authored-by: chrisadubois Co-authored-by: Kesava Krishnan Madavan Co-authored-by: Kacper Waśniowski Co-authored-by: Edmond Vujići <67634227+edvujic@users.noreply.github.com> Co-authored-by: Sreekanth Narayanan <131740035+sreenara@users.noreply.github.com> Co-authored-by: Peter Cole <55573154+peter7cole@users.noreply.github.com> Co-authored-by: rsarika <95286093+rsarika@users.noreply.github.com> Co-authored-by: akulakum <74420487+akulakum@users.noreply.github.com> Co-authored-by: Kesari3008 <65543166+Kesari3008@users.noreply.github.com> Co-authored-by: Bharath Balan <62698609+bhabalan@users.noreply.github.com> Co-authored-by: Marcin Co-authored-by: Bryce Tham Co-authored-by: shivani1211 Co-authored-by: mickelr <121160648+mickelr@users.noreply.github.com> Co-authored-by: Jordan Rowan <86778628+jor-row@users.noreply.github.com> Co-authored-by: Anna Tsukanova <38460776+antsukanova@users.noreply.github.com> Co-authored-by: John Soter Co-authored-by: jsoter Co-authored-by: Arun Ganeshan Co-authored-by: Rajesh Kumar <131742425+rarajes2@users.noreply.github.com> Co-authored-by: Shreyas Sharma <72344404+Shreyas281299@users.noreply.github.com> Co-authored-by: Stan Wang Co-authored-by: Stan Wang Co-authored-by: honghli Co-authored-by: JudyZhu <120536178+JudyZhuHz@users.noreply.github.com> Co-authored-by: jiwang7 <133743423+jiwang7@users.noreply.github.com> --- docs/index.html | 3 +- docs/samples/contact-center/app.js | 96 +- docs/samples/contact-center/index.html | 12 +- package.json | 2 +- .../internal-plugin-encryption/package.json | 2 +- .../call-diagnostic-metrics.ts | 7 +- .../src/new-metrics.ts | 24 +- .../call-diagnostic-metrics.ts | 77 ++ .../test/unit/spec/new-metrics.ts | 30 +- packages/@webex/plugin-cc/README.md | 1 - packages/@webex/plugin-cc/package.json | 3 +- packages/@webex/plugin-cc/src/cc.ts | 559 +++++++--- packages/@webex/plugin-cc/src/config.ts | 52 + packages/@webex/plugin-cc/src/constants.ts | 29 +- packages/@webex/plugin-cc/src/index.ts | 209 +++- packages/@webex/plugin-cc/src/logger-proxy.ts | 64 +- .../plugin-cc/src/metrics/MetricsManager.ts | 192 +++- .../src/metrics/behavioral-events.ts | 42 +- .../@webex/plugin-cc/src/metrics/constants.ts | 74 +- .../src/services/WebCallingService.ts | 195 +++- .../plugin-cc/src/services/agent/index.ts | 33 +- .../plugin-cc/src/services/agent/types.ts | 254 ++++- .../src/services/config/constants.ts | 205 ++++ .../plugin-cc/src/services/config/index.ts | 268 +++-- .../plugin-cc/src/services/config/types.ts | 475 ++++++++- .../plugin-cc/src/services/constants.ts | 95 ++ .../@webex/plugin-cc/src/services/core/Err.ts | 5 + .../src/services/core/GlobalTypes.ts | 20 + .../plugin-cc/src/services/core/Utils.ts | 100 +- .../src/services/core/WebexRequest.ts | 5 +- .../plugin-cc/src/services/core/aqm-reqs.ts | 17 +- .../plugin-cc/src/services/core/constants.ts | 95 ++ .../core/websocket/WebSocketManager.ts | 26 +- .../core/websocket/connection-service.ts | 7 +- .../core/websocket/keepalive.worker.js | 2 +- .../src/services/core/websocket/types.ts | 22 + .../@webex/plugin-cc/src/services/index.ts | 28 + .../src/services/task/TaskManager.ts | 42 +- .../plugin-cc/src/services/task/constants.ts | 35 + .../plugin-cc/src/services/task/dialer.ts | 22 +- .../plugin-cc/src/services/task/index.ts | 307 +++++- .../plugin-cc/src/services/task/types.ts | 724 +++++++++++-- packages/@webex/plugin-cc/src/types.ts | 411 +++++++- packages/@webex/plugin-cc/src/webex-config.ts | 39 + packages/@webex/plugin-cc/src/webex.js | 68 ++ .../@webex/plugin-cc/test/unit/spec/cc.ts | 719 +++++++++---- .../unit/spec/services/WebCallingService.ts | 51 +- .../test/unit/spec/services/config/index.ts | 29 +- .../test/unit/spec/services/core/Utils.ts | 97 +- .../unit/spec/services/task/TaskManager.ts | 4 +- .../test/unit/spec/services/task/index.ts | 214 +++- packages/@webex/plugin-cc/typedoc.json | 37 + packages/@webex/plugin-cc/typedoc.md | 86 ++ .../plugin-meetings/src/breakouts/index.ts | 69 ++ packages/@webex/plugin-meetings/src/config.ts | 1 + .../@webex/plugin-meetings/src/constants.ts | 2 + .../plugin-meetings/src/locus-info/index.ts | 45 +- .../src/locus-info/selfUtils.ts | 96 +- .../@webex/plugin-meetings/src/media/index.ts | 9 + .../plugin-meetings/src/meeting/brbState.ts | 7 + .../plugin-meetings/src/meeting/index.ts | 28 + .../plugin-meetings/src/meetings/index.ts | 21 + .../plugin-meetings/src/member/index.ts | 4 +- .../@webex/plugin-meetings/src/member/util.ts | 702 ++++++------- .../plugin-meetings/src/members/index.ts | 22 + .../plugin-meetings/src/members/request.ts | 18 + .../plugin-meetings/src/members/util.ts | 23 + .../test/unit/spec/breakouts/index.ts | 262 +++-- .../test/unit/spec/locus-info/index.js | 212 ++-- .../test/unit/spec/locus-info/selfUtils.js | 122 ++- .../test/unit/spec/media/index.ts | 62 ++ .../test/unit/spec/meeting/brbState.ts | 19 + .../test/unit/spec/meeting/index.js | 16 + .../test/unit/spec/meetings/index.js | 13 + .../test/unit/spec/members/index.js | 26 + .../test/unit/spec/members/request.js | 23 + .../test/unit/spec/members/utils.js | 42 + packages/@webex/webex-core/src/index.js | 10 + .../webex-core/src/lib/services-v2/README.md | 3 + .../src/lib/services-v2/constants.js | 21 + .../webex-core/src/lib/services-v2/index.js | 23 + .../lib/services-v2/interceptors/hostmap.js | 36 + .../services-v2/interceptors/server-error.js | 48 + .../lib/services-v2/interceptors/service.js | 101 ++ .../webex-core/src/lib/services-v2/metrics.js | 4 + .../src/lib/services-v2/service-catalog.js | 455 ++++++++ .../src/lib/services-v2/service-fed-ramp.js | 5 + .../src/lib/services-v2/service-url.js | 124 +++ .../src/lib/services-v2/services-v2.js | 971 ++++++++++++++++++ .../test/fixtures/host-catalog-v2.js | 247 +++++ .../test/unit/spec/services-v2/services-v2.js | 564 ++++++++++ yarn.lock | 46 +- 92 files changed, 9302 insertions(+), 1415 deletions(-) create mode 100644 packages/@webex/plugin-cc/typedoc.json create mode 100644 packages/@webex/plugin-cc/typedoc.md create mode 100644 packages/@webex/webex-core/src/lib/services-v2/README.md create mode 100644 packages/@webex/webex-core/src/lib/services-v2/constants.js create mode 100644 packages/@webex/webex-core/src/lib/services-v2/index.js create mode 100644 packages/@webex/webex-core/src/lib/services-v2/interceptors/hostmap.js create mode 100644 packages/@webex/webex-core/src/lib/services-v2/interceptors/server-error.js create mode 100644 packages/@webex/webex-core/src/lib/services-v2/interceptors/service.js create mode 100644 packages/@webex/webex-core/src/lib/services-v2/metrics.js create mode 100644 packages/@webex/webex-core/src/lib/services-v2/service-catalog.js create mode 100644 packages/@webex/webex-core/src/lib/services-v2/service-fed-ramp.js create mode 100644 packages/@webex/webex-core/src/lib/services-v2/service-url.js create mode 100644 packages/@webex/webex-core/src/lib/services-v2/services-v2.js create mode 100644 packages/@webex/webex-core/test/fixtures/host-catalog-v2.js create mode 100644 packages/@webex/webex-core/test/unit/spec/services-v2/services-v2.js diff --git a/docs/index.html b/docs/index.html index 615e8669718..c866bf16dd7 100644 --- a/docs/index.html +++ b/docs/index.html @@ -98,6 +98,7 @@ Calling API Reference BYoDS API Reference Encryption API Reference + WxCC API Reference

@@ -123,4 +124,4 @@ - + \ No newline at end of file diff --git a/docs/samples/contact-center/app.js b/docs/samples/contact-center/app.js index 2512f077155..b3d8c52ecfe 100644 --- a/docs/samples/contact-center/app.js +++ b/docs/samples/contact-center/app.js @@ -35,10 +35,11 @@ const idleCodesDropdown = document.querySelector('#idleCodesDropdown') const setAgentStatusButton = document.querySelector('#setAgentStatus'); const logoutAgentElm = document.querySelector('#logoutAgent'); const buddyAgentsDropdownElm = document.getElementById('buddyAgentsDropdown'); -const updateAgentDeviceTypeElm = document.querySelector('#updateAgentDeviceType'); -const updateFieldsContainer = document.querySelector('#updateAgentDeviceTypeFields'); +const updateAgentProfileElm = document.querySelector('#updateAgentProfile'); +const updateFieldsContainer = document.querySelector('#updateAgentProfileFields'); const updateLoginOptionElm = document.querySelector('#updateLoginOption'); -const updateDialNumberElm = document.querySelector('#updateDialNumber'); +const updateDialNumberElm = document.querySelector('#updateDialNumber'); +const updateTeamDropdownElm = document.querySelector('#updateTeamDropdown'); const incomingCallListener = document.querySelector('#incomingsection'); const incomingDetailsElm = document.querySelector('#incoming-task'); const answerElm = document.querySelector('#answer'); @@ -73,6 +74,9 @@ const engageElm = document.querySelector('#engageWidget'); let isBundleLoaded = false; // this is just to check before loading/using engage widgets const uploadLogsButton = document.getElementById('upload-logs'); const uploadLogsResultElm = document.getElementById('upload-logs-result'); +const agentLoginGenericError = document.getElementById('agent-login-generic-error'); +const agentLoginInputError = document.getElementById('agent-login-input-error'); +const applyupdateAgentProfileBtn = document.querySelector('#applyupdateAgentProfile'); deregisterBtn.style.backgroundColor = 'red'; @@ -767,7 +771,8 @@ function generateWebexConfig({credentials}) { appPlatform: 'testClient', fedramp: false, logger: { - level: 'info' + level: 'info', + bufferLogLevel: 'log', }, credentials, // Any other sdk config we need @@ -877,12 +882,25 @@ function register() { option.text = team.name; teamsDropdown.add(option); }); + if (updateTeamDropdownElm) { + updateTeamDropdownElm.innerHTML = teamsDropdown.innerHTML; + updateTeamDropdownElm.value = teamsDropdown.value; // sync initial selection + } + // Keep both dropdowns in sync + teamsDropdown.addEventListener('change', () => { + if (updateTeamDropdownElm) { + updateTeamDropdownElm.value = teamsDropdown.value; + } + }); + updateTeamDropdownElm.addEventListener('change', () => { + teamsDropdown.value = updateTeamDropdownElm.value; + }); const loginVoiceOptions = agentProfile.loginVoiceOptions; populateLoginOptions( loginVoiceOptions.filter((o) => agentProfile.webRtcEnabled || o !== 'BROWSER') ); - dialNumber.value = agentProfile.defaultDn ? agentProfile.defaultDn : ''; - dialNumber.disabled = agentProfile.defaultDn ? false : true; + dialNumber.value = agentProfile.dn ?? ''; + dialNumber.disabled = !agentProfile.dn; if (loginVoiceOptions.length > 0) loginAgentElm.disabled = false; if (agentProfile.isAgentLoggedIn) { @@ -942,7 +960,7 @@ function register() { console.log('Agent re-login successful', data); loginAgentElm.disabled = true; logoutAgentElm.classList.remove('hidden'); - updateAgentDeviceTypeElm.classList.remove('hidden'); + updateAgentProfileElm.classList.remove('hidden'); agentLogin.value = data.deviceType; agentDeviceType = data.deviceType; @@ -961,7 +979,7 @@ function register() { console.log('Agent station-login success', data); loginAgentElm.disabled = true; logoutAgentElm.classList.remove('hidden'); - updateAgentDeviceTypeElm.classList.remove('hidden'); + updateAgentProfileElm.classList.remove('hidden'); updateFieldsContainer.classList.add('hidden'); agentLogin.value = data.deviceType; @@ -1058,6 +1076,9 @@ async function handleAgentLogin(e) { } function doAgentLogin() { + agentLoginInputError.style.display = 'none'; + agentLoginGenericError.style.display = 'none'; + webex.cc.stationLogin({ teamId: teamsDropdown.value, loginOption: agentDeviceType, @@ -1067,7 +1088,7 @@ function doAgentLogin() { console.log('Agent Logged in successfully', response); loginAgentElm.disabled = true; logoutAgentElm.classList.remove('hidden'); - updateAgentDeviceTypeElm.classList.remove('hidden'); + updateAgentProfileElm.classList.remove('hidden'); // Read auxCode and lastStateChangeTimestamp from login response const DEFAULT_CODE = '0'; // Default code when no aux code is present const auxCodeId = response.data.auxCodeId?.trim() !== '' ? response.data.auxCodeId : DEFAULT_CODE; @@ -1079,6 +1100,13 @@ function doAgentLogin() { }).catch((error) => { console.log('Agent Login failed', error); + if(['EXTENSION', 'AGENT_DN'].includes(error.data.fieldName)) { + agentLoginInputError.innerText = error.data.message; + agentLoginInputError.style.display = 'block'; + } else { + agentLoginGenericError.innerText = error.data.message; + agentLoginGenericError.style.display = 'block'; + } }); } @@ -1105,7 +1133,7 @@ function logoutAgent() { .then((response) => { console.log('Agent logged out successfully', response); loginAgentElm.disabled = false; - updateAgentDeviceTypeElm.classList.add('hidden'); + updateAgentProfileElm.classList.add('hidden'); updateFieldsContainer.classList.add('hidden'); // Clear the timer when the agent logs out. @@ -1130,34 +1158,16 @@ function logoutAgent() { }); } -async function updateAgentDeviceType() { - const payload = { - loginOption: agentDeviceType, - dialNumber: dialNumber.value - }; - try { - const response = await webex.cc.updateAgentDeviceType(payload); - console.log('Profile updated successfully', response); - } - catch (error) { - console.error('Profile update failed', error); - alert('Profile update failed'); - } -} - -function showupdateAgentDeviceTypeUI() { - updateFieldsContainer.classList.toggle('hidden'); -} - -async function applyupdateAgentDeviceType() { +async function applyupdateAgentProfile() { const loginOption = updateLoginOptionElm.value; const newDial = loginOption === 'BROWSER' ? '' : updateDialNumberElm.value; const payload = { + teamId: updateTeamDropdownElm?.value || teamsDropdown.value, loginOption, dialNumber: newDial, }; try { - const resp = await webex.cc.updateAgentDeviceType(payload); + const resp = await webex.cc.updateAgentProfile(payload); console.log('Profile updated', resp); updateFieldsContainer.classList.add('hidden'); // Reflect new values in main UI @@ -1172,6 +1182,14 @@ async function applyupdateAgentDeviceType() { } } +function showupdateAgentProfileUI() { + // ensure update dialog reflects current team + if (updateTeamDropdownElm) { + updateTeamDropdownElm.value = teamsDropdown.value; + } + updateFieldsContainer.classList.toggle('hidden'); +} + function showAgentStatePopup(reason) { const agentStateReasonText = document.getElementById('agentStateReasonText'); agentStateSelect.innerHTML = ''; @@ -1650,8 +1668,22 @@ function populateLoginOptions(options) { }); } +idleCodesDropdown.addEventListener('change', handleAgentStatus); + updateLoginOptionElm.addEventListener('change', (e) => { updateDialNumberElm.disabled = e.target.value === 'BROWSER'; }); -idleCodesDropdown.addEventListener('change', handleAgentStatus); \ No newline at end of file +function updateApplyButtonState() { + const team = updateTeamDropdownElm.value; + const loginOption = updateLoginOptionElm.value; + const dialRequired = loginOption !== 'BROWSER'; + const dialValid = !dialRequired || updateDialNumberElm.value.trim() !== ''; + applyupdateAgentProfileBtn.disabled = !(team && loginOption && dialValid); +} + +updateTeamDropdownElm.addEventListener('change', updateApplyButtonState); +updateLoginOptionElm.addEventListener('change', updateApplyButtonState); +updateDialNumberElm.addEventListener('input', updateApplyButtonState); + +updateApplyButtonState(); \ No newline at end of file diff --git a/docs/samples/contact-center/index.html b/docs/samples/contact-center/index.html index 8e3194a02dd..954073f6da2 100644 --- a/docs/samples/contact-center/index.html +++ b/docs/samples/contact-center/index.html @@ -112,6 +112,7 @@

Agent +

NOTE: Teams are fetched automatically for the Agent Login. @@ -128,16 +129,21 @@

+ + - + -

diff --git a/package.json b/package.json index e5c84b19736..91567fce78f 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "build:dev": "NODE_ENV=development node ./tooling/index.js build", "prebuild:modules": "yarn && yarn @tools build:src && yarn @legacy-tools build:src && yarn workspace @webex/webex-core build:src && yarn @all build:src", "prebuild:docs": "rimraf ./docs/api", - "build:docs": "yarn workspace @webex/plugin-presence run build:docs && yarn workspace @webex/calling run build:docs && yarn workspace @webex/byods run build:docs && yarn workspace @webex/plugin-encryption run build:docs && documentation build --config documentation/config.yml --format html --output ./docs/api --github ./packages/webex/src/index.js ./packages/@webex/plugin-*/src/index.[tj]s --babel=./babel.config.json", + "build:docs": "yarn workspace @webex/plugin-presence run build:docs && yarn workspace @webex/calling run build:docs && yarn workspace @webex/byods run build:docs && yarn workspace @webex/plugin-cc run build:docs && yarn workspace @webex/plugin-encryption run build:docs && documentation build --config documentation/config.yml --format html --output ./docs/api --github ./packages/webex/src/index.js ./packages/@webex/plugin-*/src/index.[tj]s --babel=./babel.config.json", "check-karma-output": "./scripts/analyze-output.sh", "build:package": "node ./tooling/index.js build", "changelog:generate": "npx standard-changelog", diff --git a/packages/@webex/internal-plugin-encryption/package.json b/packages/@webex/internal-plugin-encryption/package.json index 0c42ead3a50..2e66622b0a3 100644 --- a/packages/@webex/internal-plugin-encryption/package.json +++ b/packages/@webex/internal-plugin-encryption/package.json @@ -50,7 +50,7 @@ "isomorphic-webcrypto": "^2.3.8", "lodash": "^4.17.21", "node-jose": "^2.2.0", - "node-kms": "^0.4.0", + "node-kms": "^0.4.1", "node-scr": "^0.3.0", "pkijs": "^2.1.84", "safe-buffer": "^5.2.0", 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 a51ece7d566..8da030a54d7 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 @@ -942,7 +942,7 @@ export default class CallDiagnosticMetrics extends StatelessWebexPlugin { /** * Submit Delayed Client Event CA events. Clears delayedClientEvents array after submission. */ - public submitDelayedClientEvents() { + public submitDelayedClientEvents(overrides?: Partial) { this.logger.log( CALL_DIAGNOSTIC_LOG_IDENTIFIER, 'CallDiagnosticMetrics: @submitDelayedClientEvents. Submitting delayed client events.' @@ -953,7 +953,10 @@ export default class CallDiagnosticMetrics extends StatelessWebexPlugin { } const promises = this.delayedClientEvents.map((delayedSubmitClientEventParams) => { - return this.submitClientEvent(delayedSubmitClientEventParams); + const {name, payload, options} = delayedSubmitClientEventParams; + const optionsWithOverrides: DelayedClientEvent['options'] = {...options, ...overrides}; + + return this.submitClientEvent({name, payload, options: optionsWithOverrides}); }); this.delayedClientEvents = []; diff --git a/packages/@webex/internal-plugin-metrics/src/new-metrics.ts b/packages/@webex/internal-plugin-metrics/src/new-metrics.ts index 45eb156a430..52f6d51e2a0 100644 --- a/packages/@webex/internal-plugin-metrics/src/new-metrics.ts +++ b/packages/@webex/internal-plugin-metrics/src/new-metrics.ts @@ -22,6 +22,7 @@ import { InternalEvent, SubmitClientEventOptions, Table, + DelayedClientEvent, } from './metrics.types'; import CallDiagnosticLatencies from './call-diagnostic/call-diagnostic-metrics-latencies'; import {setMetricTimings} from './call-diagnostic/call-diagnostic-metrics.util'; @@ -50,6 +51,11 @@ class Metrics extends WebexPlugin { */ delaySubmitClientEvents = false; + /** + * Overrides for delayed client events. E.g. if you want to override the correlationId for all delayed client events, you can set this to { correlationId: 'newCorrelationId' } + */ + delayedClientEventsOverrides: Partial = {}; + /** * Constructor * @param args @@ -74,7 +80,10 @@ class Metrics extends WebexPlugin { // @ts-ignore this.callDiagnosticMetrics = new CallDiagnosticMetrics({}, {parent: this.webex}); this.isReady = true; - this.setDelaySubmitClientEvents(this.delaySubmitClientEvents); + this.setDelaySubmitClientEvents({ + shouldDelay: this.delaySubmitClientEvents, + overrides: this.delayedClientEventsOverrides, + }); }); } @@ -405,13 +414,20 @@ class Metrics extends WebexPlugin { * Sets the value of delaySubmitClientEvents. If set to true, client events will be delayed until submitDelayedClientEvents is called. If * set to false, delayed client events will be submitted. * - * @param {boolean} shouldDelay - A boolean value indicating whether to delay the submission of client events. + * @param {object} options - {shouldDelay: A boolean value indicating whether to delay the submission of client events, overrides: An object containing overrides for the client events} */ - public setDelaySubmitClientEvents(shouldDelay: boolean) { + public setDelaySubmitClientEvents({ + shouldDelay, + overrides, + }: { + shouldDelay: boolean; + overrides?: Partial; + }) { this.delaySubmitClientEvents = shouldDelay; + this.delayedClientEventsOverrides = overrides || {}; if (this.isReady && !shouldDelay) { - return this.callDiagnosticMetrics.submitDelayedClientEvents(); + return this.callDiagnosticMetrics.submitDelayedClientEvents(overrides); } return Promise.resolve(); 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 6cd0bcdbf5a..7f969795fd6 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 @@ -3498,6 +3498,7 @@ describe('internal-plugin-metrics', () => { const submitToCallDiagnosticsSpy = sinon.spy(cd, 'submitToCallDiagnostics'); const options = { + meetingId: 'meetingId', correlationId: 'correlationId', }; @@ -3530,6 +3531,7 @@ describe('internal-plugin-metrics', () => { name: 'client.alert.displayed', payload: undefined, options: { + meetingId: 'meetingId', correlationId: 'correlationId', triggeredTime: now.toISOString(), }, @@ -3538,6 +3540,7 @@ describe('internal-plugin-metrics', () => { name: 'client.alert.removed', payload: undefined, options: { + meetingId: 'meetingId', correlationId: 'correlationId', triggeredTime: now.toISOString(), }, @@ -3546,6 +3549,7 @@ describe('internal-plugin-metrics', () => { name: 'client.call.aborted', payload: undefined, options: { + meetingId: 'meetingId', correlationId: 'correlationId', triggeredTime: now.toISOString(), }, @@ -3557,6 +3561,79 @@ describe('internal-plugin-metrics', () => { // should not call submitClientEvent again if delayedClientEvents was cleared assert.notCalled(submitClientEventSpy); }); + + it('calls submitClientEvent for every delayed event with overrides and clears delayedClientEvents array', () => { + const submitClientEventSpy = sinon.spy(cd, 'submitClientEvent'); + const submitToCallDiagnosticsSpy = sinon.spy(cd, 'submitToCallDiagnostics'); + + const options = { + meetingId: 'meetingId', + correlationId: 'correlationId', + }; + + const overrides = { + correlationId: 'newCorrelationId', + } + + cd.submitClientEvent({ + name: 'client.alert.displayed', + options, + delaySubmitEvent: true, + }); + + cd.submitClientEvent({ + name: 'client.alert.removed', + options, + delaySubmitEvent: true, + }); + + cd.submitClientEvent({ + name: 'client.call.aborted', + options, + delaySubmitEvent: true, + }); + + assert.notCalled(submitToCallDiagnosticsSpy); + assert.calledThrice(submitClientEventSpy); + submitClientEventSpy.resetHistory(); + + cd.submitDelayedClientEvents(overrides); + + assert.calledThrice(submitClientEventSpy); + assert.calledWith(submitClientEventSpy.firstCall, { + name: 'client.alert.displayed', + payload: undefined, + options: { + meetingId: 'meetingId', + correlationId: 'newCorrelationId', + triggeredTime: now.toISOString(), + }, + }); + assert.calledWith(submitClientEventSpy.secondCall, { + name: 'client.alert.removed', + payload: undefined, + options: { + meetingId: 'meetingId', + correlationId: 'newCorrelationId', + triggeredTime: now.toISOString(), + }, + }); + assert.calledWith(submitClientEventSpy.thirdCall, { + name: 'client.call.aborted', + payload: undefined, + options: { + meetingId: 'meetingId', + correlationId: 'newCorrelationId', + triggeredTime: now.toISOString(), + }, + }); + submitClientEventSpy.resetHistory(); + + cd.submitDelayedClientEvents(); + + // should not call submitClientEvent again if delayedClientEvents was cleared + assert.notCalled(submitClientEventSpy); + }); }); }); }); diff --git a/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts b/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts index d419818f6a1..9a459ea693e 100644 --- a/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts +++ b/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts @@ -264,19 +264,43 @@ describe('internal-plugin-metrics', () => { describe('#setDelaySubmitClientEvents', () => { it('sets delaySubmitClientEvents correctly and calls submitDelayedClientEvents when set to false', () => { sinon.assert.match(webex.internal.newMetrics.delaySubmitClientEvents, false); + sinon.assert.match(webex.internal.newMetrics.delayedClientEventsOverrides, {}); - webex.internal.newMetrics.setDelaySubmitClientEvents(true); + webex.internal.newMetrics.setDelaySubmitClientEvents({shouldDelay: true}); assert.notCalled(webex.internal.newMetrics.callDiagnosticMetrics.submitDelayedClientEvents); sinon.assert.match(webex.internal.newMetrics.delaySubmitClientEvents, true); + sinon.assert.match(webex.internal.newMetrics.delayedClientEventsOverrides, {}); - webex.internal.newMetrics.setDelaySubmitClientEvents(false); + webex.internal.newMetrics.setDelaySubmitClientEvents({shouldDelay: false, overrides: {foo: 'bar'}}); assert.calledOnce(webex.internal.newMetrics.callDiagnosticMetrics.submitDelayedClientEvents); - assert.calledWith(webex.internal.newMetrics.callDiagnosticMetrics.submitDelayedClientEvents); + assert.calledWith(webex.internal.newMetrics.callDiagnosticMetrics.submitDelayedClientEvents, {foo: 'bar'}); + + sinon.assert.match(webex.internal.newMetrics.delaySubmitClientEvents, false); + sinon.assert.match(webex.internal.newMetrics.delayedClientEventsOverrides, {foo: 'bar'}); + }); + + it('should not fail when called before webex is ready', () => { + + // Create mock + webex = mockWebex() + + webex.internal.newMetrics.callDiagnosticLatencies.saveTimestamp = sinon.stub(); + webex.internal.newMetrics.callDiagnosticLatencies.clearTimestamps = sinon.stub(); + webex.setTimingsAndFetch = sinon.stub(); sinon.assert.match(webex.internal.newMetrics.delaySubmitClientEvents, false); + + // Call the method before webex is ready, will not throw error + webex.internal.newMetrics.setDelaySubmitClientEvents({shouldDelay: false}); + webex.internal.newMetrics.setDelaySubmitClientEvents({shouldDelay: true}); + + webex.internal.newMetrics.setDelaySubmitClientEvents({shouldDelay: false}); + // Webex is ready + webex.emit('ready'); + }); it('should not fail when called before webex is ready', () => { diff --git a/packages/@webex/plugin-cc/README.md b/packages/@webex/plugin-cc/README.md index b65085525b7..ce3a88ee2d1 100644 --- a/packages/@webex/plugin-cc/README.md +++ b/packages/@webex/plugin-cc/README.md @@ -13,7 +13,6 @@ ## Getting Started The `ContactCenter` package is designed to provide a set of APIs to perform various operations for the Agent flow within Webex Contact Center. -TODO: Add the documentation links once ready - [Introduction to the Webex Web Calling SDK]() - [Quickstart guide](). diff --git a/packages/@webex/plugin-cc/package.json b/packages/@webex/plugin-cc/package.json index 4f8dd735b5f..473355f4159 100644 --- a/packages/@webex/plugin-cc/package.json +++ b/packages/@webex/plugin-cc/package.json @@ -29,6 +29,7 @@ "build:src": "webex-legacy-tools build -dest \"./dist\" -src \"./src\" -js -ts -maps && yarn build", "build": " yarn workspace @webex/calling run build:src && yarn run -T tsc --declaration true --declarationDir ./dist/types", "fix:lint": "eslint 'src/**/*.ts' --fix", + "build:docs": "typedoc --out ../../../docs/wxcc", "fix:prettier": "prettier \"src/**/*.ts\" --write", "prebuild": "rimraf dist", "test": "yarn test:style && yarn test:unit", @@ -69,7 +70,7 @@ "jest": "27.5.1", "jest-junit": "13.0.0", "prettier": "2.5.1", - "typedoc": "0.23.26", + "typedoc": "^0.25.0", "typescript": "4.9.5" } } diff --git a/packages/@webex/plugin-cc/src/cc.ts b/packages/@webex/plugin-cc/src/cc.ts index 5a780d6203d..ca54eeff619 100644 --- a/packages/@webex/plugin-cc/src/cc.ts +++ b/packages/@webex/plugin-cc/src/cc.ts @@ -1,3 +1,11 @@ +/** + * @module CCPlugin + * @packageDocumentation + * Contact Center Plugin module that provides functionality for managing contact center agents, + * handling tasks, and interacting with contact center services. This module enables integration + * with Webex Contact Center features through the WebexSDK. + */ + import {WebexPlugin} from '@webex/webex-core'; import EventEmitter from 'events'; import {v4 as uuidv4} from 'uuid'; @@ -8,10 +16,9 @@ import { WebexSDK, LoginOption, AgentLogin, - AgentDeviceUpdate, + AgentProfileUpdate, StationLoginResponse, StationLogoutResponse, - StationReLoginResponse, BuddyAgentsResponse, BuddyAgents, SubscribeRequest, @@ -30,13 +37,14 @@ import { OUTBOUND_TYPE, UNKNOWN_ERROR, MERCURY_DISCONNECTED_SUCCESS, + METHODS, } from './constants'; import {AGENT, WEB_RTC_PREFIX} from './services/constants'; import Services from './services'; import WebexRequest from './services/core/WebexRequest'; import LoggerProxy from './logger-proxy'; import {StateChange, Logout, StateChangeSuccess, AGENT_EVENTS} from './services/agent/types'; -import {getErrorDetails} from './services/core/Utils'; +import {getErrorDetails, isValidDialNumber} from './services/core/Utils'; import {Profile, WelcomeEvent, CC_EVENTS, ContactServiceQueue} from './services/config/types'; import { AGENT_STATE_AVAILABLE, @@ -52,19 +60,73 @@ import MetricsManager from './metrics/MetricsManager'; import {METRIC_EVENT_NAMES} from './metrics/constants'; import {Failure} from './services/core/GlobalTypes'; +/** + * @class ContactCenter + * @extends WebexPlugin + * @implements IContactCenter + * @description + Contact Center Plugin main class which provides functionality for agent management + * in Webex Contact Center. This includes capabilities for: + * - Agent login/logout + * - State management + * - Task handling + * - Call Controls + * - Mute/Unmute Call + * - Hold/Resume Call + * - Pause/Resume Call Recording + * - Transfer Task + * - Consult & Transfer Call + * - Outdial + * + * @example + * ```typescript + * const cc = webex.cc; + * await cc.register(); + * await cc.stationLogin({ teamId: 'team123', loginOption: 'AGENT_DN', dialNumber: '+1234567890' }); + * await cc.setAgentState({ state: 'Available' }); + * ``` + * + * @public + */ export default class ContactCenter extends WebexPlugin implements IContactCenter { + /** Plugin namespace identifier */ namespace = 'cc'; + + /** Plugin configuration */ private $config: CCPluginConfig; + + /** Reference to the Webex SDK instance */ private $webex: WebexSDK; + + /** Event emitter for handling plugin events */ private eventEmitter: EventEmitter; + + /** Agent configuration and profile information */ private agentConfig: Profile; + + /** Service for handling web-based calling functionality */ private webCallingService: WebCallingService; + + /** Core services for Contact Center operations */ private services: Services; + + /** Service for handling Webex API requests */ private webexRequest: WebexRequest; + + /** Manager for handling contact center tasks */ private taskManager: TaskManager; + + /** Manager for handling metrics and analytics */ private metricsManager: MetricsManager; + + /** Logger for the Contact Center plugin */ public LoggerProxy = LoggerProxy; + /** + * @ignore + * Creates an instance of ContactCenter plugin + * @param {any[]} args Arguments passed to plugin constructor + */ constructor(...args) { super(...args); @@ -87,7 +149,7 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter webex: this.$webex, connectionConfig: this.getConnectionConfig(), }); - this.services.webSocketManager.on('message', this.handleWebSocketMessage); + this.services.webSocketManager.on('message', this.handleWebsocketMessage); this.webCallingService = new WebCallingService(this.$webex); this.metricsManager = MetricsManager.getInstance({webex: this.$webex}); @@ -102,18 +164,30 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter }); } + /** + * Handles incoming task events and triggers appropriate notifications + * @private + * @param {ITask} task The incoming task object containing task details + */ private handleIncomingTask = (task: ITask) => { // @ts-ignore this.trigger(TASK_EVENTS.TASK_INCOMING, task); }; + /** + * Handles task hydration events for updating task data + * @private + * @param {ITask} task The task object to be hydrated with additional data + */ private handleTaskHydrate = (task: ITask) => { // @ts-ignore this.trigger(TASK_EVENTS.TASK_HYDRATE, task); }; /** - * An Incoming Call listener. + * Sets up event listeners for incoming tasks and task hydration + * Subscribes to task events from the task manager + * @private */ private incomingTaskListener() { this.taskManager.on(TASK_EVENTS.TASK_INCOMING, this.handleIncomingTask); @@ -121,9 +195,22 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter } /** - * This is used for making the CC SDK ready by setting up the cc mercury connection. + * Initializes the Contact Center SDK by setting up the web socket connections + * @returns {Promise} Agent profile information after successful registration + * @throws {Error} If registration fails + * @public + * @example + * ```typescript + * const cc = webex.cc; + * await cc.register(); + * // After registration, you can perform operations like login, state change, etc. + * ``` */ public async register(): Promise { + LoggerProxy.info('Starting CC SDK registration', { + module: CC_FILE, + method: METHODS.REGISTER, + }); try { this.metricsManager.timeEvent([ METRIC_EVENT_NAMES.WEBSOCKET_REGISTER_SUCCESS, @@ -132,6 +219,8 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter this.setupEventListeners(); const resp = await this.connectWebsocket(); + // Ensure 'dn' is always populated from 'defaultDn' + resp.dn = resp.defaultDn; const configFlags: ConfigFlags = { isEndCallEnabled: this.agentConfig.isEndCallEnabled, isEndConsultEnabled: this.agentConfig.isEndConsultEnabled, @@ -148,6 +237,11 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter ['operational'] ); + LoggerProxy.log(`CC SDK registration completed successfully with agentId: ${resp.agentId}`, { + module: CC_FILE, + method: METHODS.REGISTER, + }); + return resp; } catch (error) { this.metricsManager.trackEvent( @@ -159,7 +253,7 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter ); LoggerProxy.error(`Error during register: ${error}`, { module: CC_FILE, - method: this.register.name, + method: METHODS.REGISTER, }); this.webexRequest.uploadLogs({ correlationId: error?.trackingId, @@ -170,9 +264,19 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter } /** - * This is used to unregister the CC SDK and clean up all resources. - * @returns Promise - * @throws Error + * Unregisters the Contact Center SDK by closing all the web socket connections and removing event listeners + * @remarks + * This method does not do a station signout. + * @returns {Promise} Resolves when deregistration is complete + * @throws {Error} If deregistration fails + * @public + * @example + * ```typescript + * const cc = webex.cc; + * await cc.register(); + * // Perform operations like login, state change, etc. + * await cc.deregister(); + * ``` */ public async deregister(): Promise { try { @@ -185,7 +289,7 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter this.taskManager.off(TASK_EVENTS.TASK_HYDRATE, this.handleTaskHydrate); this.taskManager.unregisterIncomingCallEvent(); - this.services.webSocketManager.off('message', this.handleWebSocketMessage); + this.services.webSocketManager.off('message', this.handleWebsocketMessage); this.services.connectionService.off('connectionLost', this.handleConnectionLost); if ( @@ -200,7 +304,7 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter await this.$webex.internal.device.unregister(); LoggerProxy.log(MERCURY_DISCONNECTED_SUCCESS, { module: CC_FILE, - method: 'deregister', + method: METHODS.DEREGISTER, }); } } @@ -214,7 +318,7 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter LoggerProxy.log('Deregistered successfully', { module: CC_FILE, - method: 'deregister', + method: METHODS.DEREGISTER, }); this.metricsManager.trackEvent(METRIC_EVENT_NAMES.WEBSOCKET_DEREGISTER_SUCCESS, {}, [ @@ -231,7 +335,7 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter LoggerProxy.error(`Error during deregister: ${error}`, { module: CC_FILE, - method: 'deregister', + method: METHODS.DEREGISTER, }); throw error; @@ -239,14 +343,23 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter } /** - * Returns the list of buddy agents in the given state and media according to agent profile settings - * - * @param {BuddyAgents} data - The data required to fetch buddy agents, including additional agent profile information. - * @returns {Promise} A promise that resolves to the response containing buddy agents information. - * @throws Error - * @example getBuddyAgents({state: 'Available', mediaType: 'telephony'}) + * Returns the list of buddy agents who are in the given user state and media type based on their agent profile settings + * @param {BuddyAgents} data The data required to fetch buddy agents + * @returns {Promise} A promise resolving to the buddy agents information + * @throws {Error} If fetching buddy agents fails + * @example + * ```typescript + * const cc = webex.cc; + * await cc.register(); + * await cc.stationLogin({ teamId: 'team123', loginOption: 'BROWSER' }); + * await cc.getBuddyAgents({state: 'Available', mediaType: 'telephony'}); + * ``` */ public async getBuddyAgents(data: BuddyAgents): Promise { + LoggerProxy.info('Fetching buddy agents', { + module: CC_FILE, + method: METHODS.GET_BUDDY_AGENTS, + }); try { this.metricsManager.timeEvent([ METRIC_EVENT_NAMES.FETCH_BUDDY_AGENTS_SUCCESS, @@ -267,6 +380,12 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter ['operational'] ); + LoggerProxy.log(`Successfully retrieved ${resp.data.agentList.length} buddy agents`, { + module: CC_FILE, + method: METHODS.GET_BUDDY_AGENTS, + trackingId: resp.trackingId, + }); + return resp; } catch (error) { const failureResp = error.details as Failure; @@ -280,18 +399,22 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter }, ['operational'] ); - const {error: detailedError} = getErrorDetails(error, 'getBuddyAgents', CC_FILE); + const {error: detailedError} = getErrorDetails(error, METHODS.GET_BUDDY_AGENTS, CC_FILE); throw detailedError; } } /** - * This is used for connecting the websocket and fetching the agent profile. - * @returns Promise - * @throws Error + * Connects to the websocket and fetches the agent profile + * @returns {Promise} Agent profile information + * @throws {Error} If connection fails or profile cannot be fetched * @private */ private async connectWebsocket() { + LoggerProxy.info('Connecting to websocket', { + module: CC_FILE, + method: METHODS.CONNECT_WEBSOCKET, + }); try { return this.services.webSocketManager .initWebSocket({ @@ -303,7 +426,7 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter this.agentConfig = await this.services.config.getAgentConfig(orgId, agentId); LoggerProxy.log(`Agent config is fetched successfully`, { module: CC_FILE, - method: this.connectWebsocket.name, + method: METHODS.CONNECT_WEBSOCKET, }); if ( @@ -313,15 +436,15 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter this.$webex.internal.mercury .connect() .then(() => { - LoggerProxy.info('Authentication: webex.internal.mercury.connect successful', { + LoggerProxy.log('Authentication: webex.internal.mercury.connect successful', { module: CC_FILE, - method: this.connectWebsocket.name, + method: METHODS.CONNECT_WEBSOCKET, }); }) .catch((error) => { LoggerProxy.error(`Error occurred during mercury.connect() ${error}`, { module: CC_FILE, - method: this.connectWebsocket.name, + method: METHODS.CONNECT_WEBSOCKET, }); }); } @@ -337,7 +460,7 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter } catch (error) { LoggerProxy.error(`Error during register: ${error}`, { module: CC_FILE, - method: this.connectWebsocket.name, + method: METHODS.CONNECT_WEBSOCKET, }); throw error; @@ -345,17 +468,42 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter } /** - * This is used for agent login. - * @param data - * @returns Promise - * @throws Error + * Performs agent login with specified credentials and device type + * @param {AgentLogin} data Login parameters including teamId, loginOption and dialNumber + * @returns {Promise} Response containing login status and profile + * @throws {Error} If login fails + * @public + * @example + * ```typescript + * const cc = webex.cc; + * await cc.register(); + * await cc.stationLogin({ + * teamId: 'team123', + * loginOption: 'EXTENSION', + * dialNumber: '1002' + * }); + * // After successful login, you can perform operations like state change, task handling, etc. + * ``` */ public async stationLogin(data: AgentLogin): Promise { + LoggerProxy.info('Starting agent station login', { + module: CC_FILE, + method: METHODS.STATION_LOGIN, + }); try { this.metricsManager.timeEvent([ METRIC_EVENT_NAMES.STATION_LOGIN_SUCCESS, METRIC_EVENT_NAMES.STATION_LOGIN_FAILED, ]); + + if (data.loginOption === LoginOption.AGENT_DN && !isValidDialNumber(data.dialNumber)) { + const error = new Error('INVALID_DIAL_NUMBER'); + // @ts-ignore - adding custom key to the error object + error.details = {data: {reason: 'INVALID_DIAL_NUMBER'}} as Failure; + + throw error; + } + const loginResponse = this.services.agent.stationLogin({ data: { dialNumber: @@ -365,7 +513,6 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter isExtension: data.loginOption === LoginOption.EXTENSION, deviceId: this.getDeviceId(data.loginOption, data.dialNumber), roles: [AGENT], - // TODO: The public API should not have the following properties so filling them with empty values for now. If needed, we can add them in the future. teamName: EMPTY_STRING, siteId: EMPTY_STRING, usesOtherDN: false, @@ -397,13 +544,22 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter { ...MetricsManager.getCommonTrackingFieldForAQMResponse(resp), loginType: data.loginOption, - status: resp.data.status, // 'LoggedIn' - type: resp.data.type, // 'AgentStationLoginSuccess' + status: resp.data.status, + type: resp.data.type, roles: resp.data.roles?.join(',') || EMPTY_STRING, }, ['behavioral', 'business', 'operational'] ); + LoggerProxy.log( + `Agent station login completed successfully agentId: ${resp.data.agentId} loginOption: ${data.loginOption} teamId: ${data.teamId}`, + { + module: CC_FILE, + method: METHODS.STATION_LOGIN, + trackingId: resp.trackingId, + } + ); + return response; } catch (error) { const failure = error.details as Failure; @@ -415,17 +571,27 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter }, ['behavioral', 'business', 'operational'] ); - const {error: detailedError} = getErrorDetails(error, 'stationLogin', CC_FILE); + error.loginOption = data.loginOption; + const {error: detailedError} = getErrorDetails(error, METHODS.STATION_LOGIN, CC_FILE); + throw detailedError; } } - /** This is used for agent logout. - * @param data - * @returns Promise - * @throws Error + /** + * Performs a station logout operation for the agent + * @remarks + * A logout operation cannot happen if the agent is in an interaction or haven't logged in yet. + * @param {Logout} data Logout parameters + * @returns {Promise} Response indicating logout status + * @throws {Error} If logout fails + * @public */ public async stationLogout(data: Logout): Promise { + LoggerProxy.info('Starting agent station logout', { + module: CC_FILE, + method: METHODS.STATION_LOGOUT, + }); try { this.metricsManager.timeEvent([ METRIC_EVENT_NAMES.STATION_LOGOUT_SUCCESS, @@ -450,6 +616,12 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter this.webCallingService.deregisterWebCallingLine(); } + LoggerProxy.log(`Agent station logout completed successfully`, { + module: CC_FILE, + method: METHODS.STATION_LOGOUT, + trackingId: resp.trackingId, + }); + return resp; } catch (error) { const failure = error.details as Failure; @@ -461,45 +633,18 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter }, ['behavioral', 'business', 'operational'] ); - const {error: detailedError} = getErrorDetails(error, 'stationLogout', CC_FILE); + const {error: detailedError} = getErrorDetails(error, METHODS.STATION_LOGOUT, CC_FILE); throw detailedError; } } - /* This is used for agent relogin. - * @returns Promise - * @throws Error + /** + * Gets the device ID based on login option and dial number + * @param {string} loginOption The login option (BROWSER, EXTENSION, etc) + * @param {string} dialNumber The dial number if applicable + * @returns {string} The device ID + * @private */ - public async stationReLogin(): Promise { - try { - this.metricsManager.timeEvent([ - METRIC_EVENT_NAMES.STATION_RELOGIN_SUCCESS, - METRIC_EVENT_NAMES.STATION_RELOGIN_FAILED, - ]); - const reLoginResponse = await this.services.agent.reload(); - this.metricsManager.trackEvent( - METRIC_EVENT_NAMES.STATION_RELOGIN_SUCCESS, - { - ...MetricsManager.getCommonTrackingFieldForAQMResponse(reLoginResponse), - }, - ['behavioral', 'business', 'operational'] - ); - - return reLoginResponse; - } catch (error) { - const failure = error.details as Failure; - this.metricsManager.trackEvent( - METRIC_EVENT_NAMES.STATION_RELOGIN_FAILED, - { - ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed(failure), - }, - ['behavioral', 'business', 'operational'] - ); - const {error: detailedError} = getErrorDetails(error, 'stationReLogin', CC_FILE); - throw detailedError; - } - } - private getDeviceId(loginOption: string, dialNumber: string): string { if (loginOption === LoginOption.EXTENSION || loginOption === LoginOption.AGENT_DN) { return dialNumber; @@ -509,13 +654,29 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter } /** - * This is used for setting agent state. - * @param options - * @returns Promise - * @throws Error + * Sets the state of the agent to Available or any of the Idle states + * @param {StateChange} data State change parameters including the new state + * @returns {Promise} Response with updated state information + * @throws {Error} If state change fails + * @public + * @example + * ```typescript + * const cc = webex.cc; + * await cc.register(); + * await cc.stationLogin({ teamId: 'team123', loginOption: 'BROWSER' }); + * await cc.setAgentState({ + * state: 'Available', + * auxCodeId: '12345', + * lastStateChangeReason: 'Manual state change', + * agentId: 'agent123', + * }); + * ``` */ - public async setAgentState(data: StateChange): Promise { + LoggerProxy.info('Setting agent state', { + module: CC_FILE, + method: METHODS.SET_AGENT_STATE, + }); try { this.metricsManager.timeEvent([ METRIC_EVENT_NAMES.AGENT_STATE_CHANGE_SUCCESS, @@ -539,10 +700,14 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter ['behavioral', 'business', 'operational'] ); - LoggerProxy.log(`SET AGENT STATUS API SUCCESS`, { - module: CC_FILE, - method: this.setAgentState.name, - }); + LoggerProxy.log( + `Agent state changed successfully to auxCodeId: ${agentStatusResponse.data.auxCodeId}`, + { + module: CC_FILE, + method: METHODS.SET_AGENT_STATE, + trackingId: agentStatusResponse.trackingId, + } + ); return agentStatusResponse; } catch (error) { @@ -557,12 +722,19 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter }, ['behavioral', 'business', 'operational'] ); - const {error: detailedError} = getErrorDetails(error, 'setAgentState', CC_FILE); + const {error: detailedError} = getErrorDetails(error, METHODS.SET_AGENT_STATE, CC_FILE); throw detailedError; } } - private handleWebSocketMessage = (event: string) => { + /** + * Processes incoming websocket messages and emits corresponding events + * Handles various event types including agent state changes, login events, + * and other agent-related notifications + * @private + * @param {string} event The raw websocket event message + */ + private handleWebsocketMessage = (event: string) => { const eventData = JSON.parse(event); // Re-emit all the events related to agent except keep-alives if (!eventData.keepalive && eventData.data && eventData.data.type) { @@ -574,6 +746,11 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter return; } + LoggerProxy.log(`Received event: ${eventData.type}`, { + module: CC_FILE, + method: METHODS.HANDLE_WEBSOCKET_MESSAGE, + }); + switch (eventData.type) { case CC_EVENTS.AGENT_MULTI_LOGIN: // @ts-ignore @@ -604,6 +781,7 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter }, notifsTrackingId: eventData.trackingId, }; + this.webCallingService.setLoginOption(loginData.deviceType as LoginOption); // @ts-ignore this.emit(AGENT_EVENTS.AGENT_STATION_LOGIN_SUCCESS, stationLoginData); break; @@ -655,14 +833,18 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter }; /** - * For setting up the Event Emitter listeners and handlers + * Initializes event listeners for the Contact Center service + * Sets up handlers for connection state changes and other core events + * @private */ private setupEventListeners() { this.services.connectionService.on('connectionLost', this.handleConnectionLost.bind(this)); } /** - * This method returns the connection configuration. + * Returns the connection configuration + * @returns {SubscribeRequest} Connection configuration + * @private */ private getConnectionConfig(): SubscribeRequest { return { @@ -674,20 +856,22 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter } /** - * Called when the reconnection has been completed + * Handles connection lost events and reconnection attempts + * @param {ConnectionLostDetails} msg Connection lost details + * @private */ private async handleConnectionLost(msg: ConnectionLostDetails): Promise { if (msg.isConnectionLost) { // TODO: Emit an event saying connection is lost LoggerProxy.info('event=handleConnectionLost | Connection lost', { module: CC_FILE, - method: this.handleConnectionLost.name, + method: METHODS.HANDLE_CONNECTION_LOST, }); } else if (msg.isSocketReconnected) { // TODO: Emit an event saying connection is re-estabilished LoggerProxy.info( 'event=handleConnectionReconnect | Connection reconnected attempting to request silent relogin', - {module: CC_FILE, method: this.handleConnectionLost.name} + {module: CC_FILE, method: METHODS.HANDLE_CONNECTION_LOST} ); if (this.$config && this.$config.allowAutomatedRelogin) { await this.silentRelogin(); @@ -696,9 +880,15 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter } /** - * Called when we finish registration to silently handle the errors + * Handles silent relogin after registration completion + * @private */ private async silentRelogin(): Promise { + LoggerProxy.info('Starting silent relogin process', { + module: CC_FILE, + method: METHODS.SILENT_RELOGIN, + }); + try { const reLoginResponse = await this.services.agent.reload(); const { @@ -718,7 +908,7 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter if (lastStateChangeReason === 'agent-wss-disconnect') { LoggerProxy.info( 'event=requestAutoStateChange | Requesting state change to available on socket reconnect', - {module: CC_FILE, method: this.silentRelogin.name} + {module: CC_FILE, method: METHODS.SILENT_RELOGIN} ); auxCodeId = AGENT_STATE_AVAILABLE_ID; const stateChangeData: StateChange = { @@ -739,20 +929,33 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter } catch (error) { LoggerProxy.error( `event=requestAutoStateChange | Error requesting state change to available on socket reconnect: ${error}`, - {module: CC_FILE, method: this.silentRelogin.name} + {module: CC_FILE, method: METHODS.SILENT_RELOGIN} ); } } this.agentConfig.lastStateAuxCodeId = auxCodeId; this.agentConfig.isAgentLoggedIn = true; // TODO: https://jira-eng-gpk2.cisco.com/jira/browse/SPARK-626777 Implement the de-register method and close the listener there - this.services.webSocketManager.on('message', this.handleWebSocketMessage); + this.services.webSocketManager.on('message', this.handleWebsocketMessage); + + LoggerProxy.log( + `Silent relogin process completed successfully with login Option: ${reLoginResponse.data.deviceType} teamId: ${reLoginResponse.data.teamId}`, + { + module: CC_FILE, + method: METHODS.SILENT_RELOGIN, + trackingId: reLoginResponse.trackingId, + } + ); } catch (error) { - const {reason, error: detailedError} = getErrorDetails(error, 'silentReLogin', CC_FILE); + const {reason, error: detailedError} = getErrorDetails( + error, + METHODS.SILENT_RELOGIN, + CC_FILE + ); if (reason === 'AGENT_NOT_FOUND') { - LoggerProxy.log('Agent not found during re-login, handling silently', { + LoggerProxy.log('Agent not found during relogin, handling silently', { module: CC_FILE, - method: 'silentRelogin', + method: METHODS.SILENT_RELOGIN, }); return; @@ -762,23 +965,37 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter } /** - * Handles the device type specific logic + * Handles device type specific configuration and setup + * Configures services and settings based on the login device type + * @param {LoginOption} deviceType The type of device being used for login + * @param {string} dn The dial number associated with the device + * @returns {Promise} + * @private */ private async handleDeviceType(deviceType: LoginOption, dn: string): Promise { this.webCallingService.setLoginOption(deviceType); this.agentConfig.deviceType = deviceType; switch (deviceType) { case LoginOption.BROWSER: - await this.webCallingService.registerWebCallingLine(); + try { + await this.webCallingService.registerWebCallingLine(); + } catch (error) { + LoggerProxy.error(`Error registering web calling line: ${error}`, { + module: CC_FILE, + method: METHODS.HANDLE_DEVICE_TYPE, + }); + throw error; + } break; case LoginOption.AGENT_DN: case LoginOption.EXTENSION: this.agentConfig.defaultDn = dn; + this.agentConfig.dn = dn; break; default: LoggerProxy.error(`Unsupported device type: ${deviceType}`, { module: CC_FILE, - method: this.handleDeviceType.name, + method: METHODS.HANDLE_DEVICE_TYPE, }); throw new Error(`Unsupported device type: ${deviceType}`); } @@ -786,17 +1003,25 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter /** * This is used for making the outdial call. - * @param destination - * @returns Promise - * @throws Error + * @param destination - The destination number to dial + * @returns Promise Resolves with the outdial task response + * @throws Error If the outdial operation fails + * @public * @example * ```typescript - * const destination = '1234567890'; - * const result = await webex.cc.startOutdial(destination).then(()=>{}).catch(()=>{}); + * const destination = '+1234567890'; + * const cc = webex.cc; + * await cc.register(); + * const task = await cc.startOutdial(destination); + * // Can do task operations like accept, reject, etc. * ``` + * Refer to {@link ITask | ITask interface} for more details. */ - public async startOutdial(destination: string): Promise { + LoggerProxy.info('Starting outbound dial', { + module: CC_FILE, + method: METHODS.START_OUTDIAL, + }); try { this.metricsManager.timeEvent([ METRIC_EVENT_NAMES.TASK_OUTDIAL_SUCCESS, @@ -825,6 +1050,13 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter ['behavioral', 'business', 'operational'] ); + LoggerProxy.log(`Outbound dial completed successfully`, { + module: CC_FILE, + method: METHODS.START_OUTDIAL, + trackingId: result.trackingId, + interactionId: result.data?.interactionId, + }); + return result; } catch (error) { const failure = error.details as Failure; @@ -837,27 +1069,26 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter }, ['behavioral', 'business', 'operational'] ); - const {error: detailedError} = getErrorDetails(error, 'startOutdial', CC_FILE); + const {error: detailedError} = getErrorDetails(error, METHODS.START_OUTDIAL, CC_FILE); throw detailedError; } } /** - * This is used for getting the list of queues. - * @param search - optional - * @param filter - optional - * @param page - default is 0 - * @param pageSize - default is 100 - * @returns Promise - * @throws Error - * + * This is used for getting the list of queues to which a task can be consulted or transferred. + * @param search - optional search string + * @param filter - optional filter string + * @param page - page number (default is 0) + * @param pageSize - number of items per page (default is 100) + * @returns Promise Resolves with the list of queues + * @throws Error If the operation fails + * @public * @example * ```typescript - * const search = 'queue'; - * const filter = 'id == "e23ad456-1ebd-1b43-b9d0-34f39c7dcb5e"'; - * const page = 0; - * const pageSize = 100; - * const result = await webex.cc.getQueues(search, filter, page, pageSize); + * const cc = webex.cc; + * await cc.register(); + * await cc.stationLogin({ teamId: 'team123', loginOption: 'BROWSER' }); + * const queues = await cc.getQueues(); * ``` */ public async getQueues( @@ -866,18 +1097,30 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter page = DEFAULT_PAGE, pageSize = DEFAULT_PAGE_SIZE ): Promise { + LoggerProxy.info('Fetching queues', { + module: CC_FILE, + method: METHODS.GET_QUEUES, + }); + const orgId = this.$webex.credentials.getOrgId(); if (!orgId) { LoggerProxy.error('Org ID not found.', { module: CC_FILE, - method: this.getQueues.name, + method: METHODS.GET_QUEUES, }); throw new Error('Org ID not found.'); } - return this.services.config.getQueues(orgId, page, pageSize, search, filter); + const result = await this.services.config.getQueues(orgId, page, pageSize, search, filter); + + LoggerProxy.log(`Successfully retrieved ${result?.length} queues`, { + module: CC_FILE, + method: METHODS.GET_QUEUES, + }); + + return result; } /** @@ -887,8 +1130,20 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter * messages, and client-side events, then securely submits them to Webex's diagnostics * service. The returned tracking ID, feedbackID can be provided to Webex support for faster * issue resolution. - * @returns Promise - * @throws Error + * @returns Promise Resolves with the upload logs response + * @throws Error If the upload fails + * @public + * @example + * ```typescript + * const cc = webex.cc; + * try { + * await cc.register(); + * } + * catch (error) { + * console.error('Error during registration:', error); + * cc.uploadLogs(); + * } + * ``` */ public async uploadLogs(): Promise { return this.webexRequest.uploadLogs(); @@ -899,18 +1154,24 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter * This method allows the agent to change their device type (e.g., from BROWSER to EXTENSION or anything else). * It will also throw an error if the new device type is the same as the current one. * @param data type is AgentDeviceUpdate - The data required to update the agent device type, including the new login option and dial number. - * @returns Promise - * @throws Error + * @returns Promise Resolves with the device type update response + * @throws Error If the update fails * @example * ```typescript * const data = { * loginOption: 'EXTENSION', * dialNumber: '1234567890', + * teamId: 'team-id-if-needed', // Optional, if not provided, current team ID will be used * }; - * const result = await webex.cc.updateAgentDeviceType(data); + * const result = await webex.cc.updateAgentProfile(data); + * const cc = webex.cc; + * await cc.register(); + * await cc.stationLogin({ teamId: 'team123', loginOption: 'BROWSER' }); + * await cc.updateAgentDeviceType(data); * ``` + * @public */ - public async updateAgentDeviceType(data: AgentDeviceUpdate): Promise { + public async updateAgentProfile(data: AgentProfileUpdate): Promise { this.metricsManager.timeEvent([ METRIC_EVENT_NAMES.AGENT_DEVICE_TYPE_UPDATE_SUCCESS, METRIC_EVENT_NAMES.AGENT_DEVICE_TYPE_UPDATE_FAILED, @@ -918,16 +1179,20 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter const trackingId = `WX_CC_SDK_${uuidv4()}`; - LoggerProxy.info(`[${trackingId}] updateAgentDeviceType | starting profile update`, { + LoggerProxy.info(`starting profile update`, { module: CC_FILE, - method: this.updateAgentDeviceType.name, + method: METHODS.UPDATE_AGENT_PROFILE, + trackingId, }); try { - // ensure we change device type - if (this.webCallingService?.loginOption === data.loginOption) { + // Only block if both loginOption AND teamId remain unchanged + if ( + this.webCallingService?.loginOption === data.loginOption && + data.teamId === this.agentConfig.currentTeamId + ) { const message = - 'Will not proceed with device update as new Device type is same as current device type'; + 'Will not proceed with device update as new Device type is same as current device type and teamId is same as current teamId'; const err = new Error(message) as GenericError; err.details = { type: 'Identical Device Change Failure', @@ -947,7 +1212,7 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter }); const loginPayload: AgentLogin = { - teamId: this.agentConfig.currentTeamId ?? EMPTY_STRING, + teamId: data.teamId, loginOption: data.loginOption, dialNumber: data.dialNumber, }; @@ -963,10 +1228,14 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter ['behavioral', 'business', 'operational'] ); - LoggerProxy.log(`[${trackingId}] updateAgentDeviceType | profile updated successfully`, { - module: CC_FILE, - method: this.updateAgentDeviceType.name, - }); + LoggerProxy.log( + `profile updated successfully with ${loginPayload.loginOption} teamId: ${loginPayload.teamId}`, + { + module: CC_FILE, + method: METHODS.UPDATE_AGENT_PROFILE, + trackingId, + } + ); const deviceTypeUpdateResponse: UpdateDeviceTypeResponse = { ...resp, @@ -985,13 +1254,11 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter ['behavioral', 'business', 'operational'] ); - LoggerProxy.error( - `[${trackingId}] updateAgentDeviceType | error updating profile: ${error}`, - { - module: CC_FILE, - method: this.updateAgentDeviceType.name, - } - ); + LoggerProxy.error(`error updating profile: ${error}`, { + module: CC_FILE, + method: METHODS.UPDATE_AGENT_PROFILE, + trackingId, + }); throw error; } } diff --git a/packages/@webex/plugin-cc/src/config.ts b/packages/@webex/plugin-cc/src/config.ts index bfd0c2189e4..829e4c25524 100644 --- a/packages/@webex/plugin-cc/src/config.ts +++ b/packages/@webex/plugin-cc/src/config.ts @@ -1,12 +1,64 @@ +/** + * Default configuration for the Webex Contact Center SDK. + * + * @public + * @example + * import config from './config'; + * const allowMultiLogin = config.cc.allowMultiLogin; + * @ignore + */ export default { + /** + * Contact Center configuration options. + * @public + */ cc: { + /** + * Whether to allow multiple logins from different devices. + * @type {boolean} + * @default false + */ allowMultiLogin: false, + /** + * Whether to automatically attempt relogin on connection loss. + * @type {boolean} + * @default true + */ allowAutomatedRelogin: true, + /** + * The type of client making the connection. + * @type {string} + * @default 'WebexCCSDK' + */ clientType: 'WebexCCSDK', + /** + * Whether to enable keep-alive messages. + * @type {boolean} + * @default false + */ isKeepAliveEnabled: false, + /** + * Whether to force registration. + * @type {boolean} + * @default true + */ force: true, + /** + * Metrics configuration for the client. + * @public + */ metrics: { + /** + * Name of the client for metrics. + * @type {string} + * @default 'WEBEX_JS_SDK' + */ clientName: 'WEBEX_JS_SDK', + /** + * Type of client for metrics. + * @type {string} + * @default 'WebexCCSDK' + */ clientType: 'WebexCCSDK', }, }, diff --git a/packages/@webex/plugin-cc/src/constants.ts b/packages/@webex/plugin-cc/src/constants.ts index 7ea8c04c076..d4c3dfaa817 100644 --- a/packages/@webex/plugin-cc/src/constants.ts +++ b/packages/@webex/plugin-cc/src/constants.ts @@ -13,12 +13,39 @@ export const WEB_SOCKET_MANAGER_FILE = 'WebSocketManager'; export const AQM_REQS_FILE = 'aqm-reqs'; export const WEBEX_REQUEST_FILE = 'WebexRequest'; export const TASK_MANAGER_FILE = 'TaskManager'; +export const TASK_FILE = 'Task'; // AGENT OUTDIAL CONSTANTS export const OUTDIAL_DIRECTION = 'OUTBOUND'; export const ATTRIBUTES = {}; export const OUTDIAL_MEDIA_TYPE = 'telephony'; export const OUTBOUND_TYPE = 'OUTDIAL'; -// add these: +// Log related constants export const UNKNOWN_ERROR = 'Unknown error'; export const MERCURY_DISCONNECTED_SUCCESS = 'Mercury disconnected successfully'; + +// METHOD NAMES +export const METHODS = { + REGISTER: 'register', + DEREGISTER: 'deregister', + GET_BUDDY_AGENTS: 'getBuddyAgents', + CONNECT_WEBSOCKET: 'connectWebsocket', + STATION_LOGIN: 'stationLogin', + STATION_LOGOUT: 'stationLogout', + STATION_RELOGIN: 'stationReLogin', + SET_AGENT_STATE: 'setAgentState', + HANDLE_WEBSOCKET_MESSAGE: 'handleWebsocketMessage', + SETUP_EVENT_LISTENERS: 'setupEventListeners', + GET_CONNECTION_CONFIG: 'getConnectionConfig', + HANDLE_CONNECTION_LOST: 'handleConnectionLost', + SILENT_RELOGIN: 'silentRelogin', + HANDLE_DEVICE_TYPE: 'handleDeviceType', + START_OUTDIAL: 'startOutdial', + GET_QUEUES: 'getQueues', + UPLOAD_LOGS: 'uploadLogs', + UPDATE_AGENT_PROFILE: 'updateAgentProfile', + GET_DEVICE_ID: 'getDeviceId', + HANDLE_INCOMING_TASK: 'handleIncomingTask', + HANDLE_TASK_HYDRATE: 'handleTaskHydrate', + INCOMING_TASK_LISTENER: 'incomingTaskListener', +}; diff --git a/packages/@webex/plugin-cc/src/index.ts b/packages/@webex/plugin-cc/src/index.ts index b133b896d54..d8d49e654ad 100644 --- a/packages/@webex/plugin-cc/src/index.ts +++ b/packages/@webex/plugin-cc/src/index.ts @@ -1,13 +1,220 @@ import {registerPlugin} from '@webex/webex-core'; - import config from './config'; import ContactCenter from './cc'; +/** @module ContactCenterModule */ + +// Core exports +/** + * ContactCenter is the main plugin class for Webex Contact Center integration + * @category Core + */ +export {default as ContactCenter} from './cc'; + +// Service exports +/** + * Task class represents a contact center task that can be managed by an agent + * @category Services + */ +export {default as Task} from './services/task'; + +/** + * Agent routing service for Contact Center operations + * @category Services + */ +export {default as routingAgent} from './services/agent'; + +// Enums +/** + * Task Events for Contact Center operations + * @enum {string} + * @category Enums + */ export {TASK_EVENTS} from './services/task/types'; +export type {TASK_EVENTS as TaskEvents} from './services/task/types'; + +/** + * Agent Events for Contact Center operations + * @enum {string} + * @category Enums + */ export {AGENT_EVENTS} from './services/agent/types'; +export type {AGENT_EVENTS as AgentEvents} from './services/agent/types'; + +/** + * Contact Center Task Events + * @enum {string} + * @category Enums + */ +export {CC_TASK_EVENTS} from './services/config/types'; + +/** + * Contact Center Agent Events + * @enum {string} + * @category Enums + */ +export {CC_AGENT_EVENTS} from './services/config/types'; + +/** + * Combined Contact Center Events + * @enum {string} + * @category Enums + */ +export {CC_EVENTS} from './services/config/types'; +export type {CC_EVENTS as ContactCenterEvents} from './services/config/types'; + +// Interfaces +/** Main types and interfaces for Contact Center functionality */ +export type { + /** Interface for Contact Center plugin */ + IContactCenter, + /** Configuration options for Contact Center plugin */ + CCPluginConfig, + /** WebexSDK interface */ + WebexSDK, +} from './types'; + +// Types +/** Agent related types */ +export type { + /** Login options for agents */ + LoginOption, + /** Agent login information */ + AgentLogin, + /** Agent device update information */ + AgentProfileUpdate, + /** Station login response */ + StationLoginResponse, + /** Station logout response */ + StationLogoutResponse, + /** Buddy agents response */ + BuddyAgentsResponse, + /** Buddy agents information */ + BuddyAgents, + /** Subscribe request parameters */ + SubscribeRequest, + /** Upload logs response */ + UploadLogsResponse, + /** Update device type response */ + UpdateDeviceTypeResponse, + /** Generic error interface */ + GenericError, + /** Set state response */ + SetStateResponse, +} from './types'; + +/** Task related types */ +export type { + AgentContact, + /** Task interface */ + ITask, + TaskData, + /** Task response */ + TaskResponse, + ConsultPayload, + ConsultEndPayload, + ConsultTransferPayLoad, + /** Dialer payload */ + DialerPayload, + TransferPayLoad, + ResumeRecordingPayload, + WrapupPayLoad, +} from './services/task/types'; + +/** Agent related types */ +export type { + /** State change interface */ + StateChange, + /** Logout interface */ + Logout, + /** State change success response */ + StateChangeSuccess, + /** Station login success response */ + StationLoginSuccess, + /** Extended station login success response with notification tracking */ + StationLoginSuccessResponse, + /** Device type update success response */ + DeviceTypeUpdateSuccess, + /** Agent login success response */ + LogoutSuccess, + /** Agent relogin success response */ + ReloginSuccess, + /** Agent state type */ + AgentState, + /** User station login parameters */ + UserStationLogin, + /** Device type for agent login */ + DeviceType, + /** Buddy agent details */ + BuddyDetails, + /** Buddy agents success response */ + BuddyAgentsSuccess, +} from './services/agent/types'; + +/** Config related types */ +export type { + /** Profile interface */ + Profile, + /** Contact service queue interface */ + ContactServiceQueue, + /** Response type from getUserUsingCI method */ + AgentResponse, + /** Response from getDesktopProfileById */ + DesktopProfileResponse, + /** Response from getMultimediaProfileById */ + MultimediaProfileResponse, + /** Response from getListOfTeams */ + ListTeamsResponse, + /** Response from getListOfAuxCodes */ + ListAuxCodesResponse, + /** Response from getSiteInfo */ + SiteInfo, + /** Response from getOrgInfo */ + OrgInfo, + /** Response from getOrganizationSetting */ + OrgSettings, + /** Response from getTenantData */ + TenantData, + /** Response from getURLMapping */ + URLMapping, + /** Response from getDialPlanData */ + DialPlanEntity, + /** Auxiliary code information */ + AuxCode, + /** Team information */ + TeamList, + /** Wrap-up reason information */ + WrapUpReason, + /** WebSocket event data */ + WebSocketEvent, + /** Wrap-up configuration data */ + WrapupData, + /** Base entity type */ + Entity, + /** Dial plan configuration */ + DialPlan, + /** Auxiliary code type (IDLE_CODE or WRAP_UP_CODE) */ + AuxCodeType, +} from './services/config/types'; + +// Constants +/** + * Idle code constant + * @constant {string} + * @category Enums + */ +export {IDLE_CODE} from './services/config/types'; + +/** + * Wrap up code constant + * @constant {string} + * @category Enums + */ +export {WRAP_UP_CODE} from './services/config/types'; registerPlugin('cc', ContactCenter, { config, }); +/** The Contact Center plugin default export */ export default ContactCenter; diff --git a/packages/@webex/plugin-cc/src/logger-proxy.ts b/packages/@webex/plugin-cc/src/logger-proxy.ts index 0c4d849479c..c7945115308 100644 --- a/packages/@webex/plugin-cc/src/logger-proxy.ts +++ b/packages/@webex/plugin-cc/src/logger-proxy.ts @@ -1,48 +1,110 @@ import {Logger, LogContext, LOGGING_LEVEL} from './types'; import {LOG_PREFIX} from './constants'; +/** + * LoggerProxy acts as a static proxy to route logging calls to an injected logger implementation. + * Ensures a consistent log format and centralizes logging behavior for the SDK. + * @ignore + */ export default class LoggerProxy { + /** + * The static logger instance to be used by the proxy. + * @ignore + */ public static logger: Logger; + /** + * Initializes the logger proxy with a provided logger implementation. + * + * @param {Logger} logger - A logger object implementing standard logging methods. + * @ignore + */ public static initialize(logger: Logger): void { LoggerProxy.logger = logger; } + /** + * Logs a generic message using the default log level. + * + * @param {string} message - The log message. + * @param {LogContext} [context={}] - Optional context providing module and method names. + * @ignore + */ public static log(message: string, context: LogContext = {}): void { if (LoggerProxy.logger) { LoggerProxy.logger.log(LoggerProxy.format(LOGGING_LEVEL.log, message, context)); } } + /** + * Logs an informational message. + * + * @param {string} message - The log message. + * @param {LogContext} [context={}] - Optional context providing module and method names. + * @ignore + */ public static info(message: string, context: LogContext = {}): void { if (LoggerProxy.logger) { LoggerProxy.logger.info(LoggerProxy.format(LOGGING_LEVEL.info, message, context)); } } + /** + * Logs a warning message. + * + * @param {string} message - The warning message. + * @param {LogContext} [context={}] - Optional context providing module and method names. + * @ignore + */ public static warn(message: string, context: LogContext = {}): void { if (LoggerProxy.logger) { LoggerProxy.logger.warn(LoggerProxy.format(LOGGING_LEVEL.warn, message, context)); } } + /** + * Logs a trace-level message, useful for debugging. + * + * @param {string} message - The trace message. + * @param {LogContext} [context={}] - Optional context providing module and method names. + * @ignore + */ public static trace(message: string, context: LogContext = {}): void { if (LoggerProxy.logger) { LoggerProxy.logger.trace(LoggerProxy.format(LOGGING_LEVEL.trace, message, context)); } } + /** + * Logs an error message. + * + * @param {string} message - The error message. + * @param {LogContext} [context={}] - Optional context providing module and method names. + * @ignore + */ public static error(message: string, context: LogContext = {}): void { if (LoggerProxy.logger) { LoggerProxy.logger.error(LoggerProxy.format(LOGGING_LEVEL.error, message, context)); } } + /** + * Formats a log message with timestamp, log level, and context details. + * + * @private + * @param {LOGGING_LEVEL} level - Logging level (e.g., info, error). + * @param {string} message - The message to be logged. + * @param {LogContext} context - Context containing module and method metadata. + * @returns {string} The formatted log string. + * @ignore + */ private static format(level: LOGGING_LEVEL, message: string, context: LogContext): string { const timestamp = new Date().toISOString(); const moduleName = context.module || 'unknown'; const methodName = context.method || 'unknown'; + const interactionId = context.interactionId ? ` - interactionId:${context.interactionId}` : ''; + const trackingId = context.trackingId ? ` - trackingId:${context.trackingId}` : ''; - return `${timestamp} ${LOG_PREFIX} - [${level}]: module:${moduleName} - method:${methodName} - ${message}`; + return `${timestamp} ${LOG_PREFIX} - [${level}]: module:${moduleName} - method:${methodName}${interactionId}${trackingId} - ${message}`; } } diff --git a/packages/@webex/plugin-cc/src/metrics/MetricsManager.ts b/packages/@webex/plugin-cc/src/metrics/MetricsManager.ts index 5b6ea8938bf..ee977dab5f3 100644 --- a/packages/@webex/plugin-cc/src/metrics/MetricsManager.ts +++ b/packages/@webex/plugin-cc/src/metrics/MetricsManager.ts @@ -25,27 +25,91 @@ type GenericEvent = { export type MetricsType = 'behavioral' | 'operational' | 'business'; const PRODUCT_NAME_UPPER = PRODUCT_NAME.toUpperCase(); +/** + * @class MetricsManager + * @classdesc Manages the collection, batching, and submission of behavioral, operational, and business metrics for the Webex SDK. + * Implements a singleton pattern to ensure a single instance throughout the application lifecycle. + * + * @remarks + * This class is responsible for tracking, batching, and submitting various types of metric events. + * It also provides utility methods for extracting common tracking fields from AQM responses. + * @ignore + */ export default class MetricsManager { + /** + * The Webex SDK instance used for submitting metrics. + * @private + */ private webex: WebexSDK; + + /** + * Stores currently running timed events. + * @private + */ private readonly runningEvents: Record}> = {}; + + /** + * Queue for pending behavioral events. + * @private + */ private pendingBehavioralEvents: BehavioralEvent[] = []; + + /** + * Queue for pending operational events. + * @private + */ private pendingOperationalEvents: GenericEvent[] = []; + + /** + * Queue for pending business events. + * @private + */ private pendingBusinessEvents: GenericEvent[] = []; + + /** + * Indicates if the manager is ready to submit events. + * @private + */ private readyToSubmitEvents = false; + + /** + * Lock to prevent concurrent submissions. + * @private + */ private submittingEvents = false; // Add a lock for submitting events - // eslint-disable-next-line no-use-before-define + /** + * Singleton instance of MetricsManager. + * @private + */ private static instance: MetricsManager; + + /** + * Flag to disable metrics collection. + * @private + */ private metricsDisabled = false; // TODO: SPARK-637285 + /** + * Private constructor to enforce singleton pattern. + * @private + */ // eslint-disable-next-line no-useless-constructor private constructor() {} + /** + * Marks the manager as ready to submit events and triggers submission. + * @private + */ private setReadyToSubmitEvents() { this.readyToSubmitEvents = true; this.submitPendingEvents(); } + /** + * Submits all pending events if not already submitting. + * @private + */ private async submitPendingEvents() { if (this.submittingEvents) { return; @@ -60,6 +124,10 @@ export default class MetricsManager { } } + /** + * Submits all pending behavioral events if ready. + * @private + */ private async submitPendingBehavioralEvents() { if (this.pendingBehavioralEvents.length === 0) { return; @@ -79,6 +147,10 @@ export default class MetricsManager { } } + /** + * Submits all pending operational events if ready. + * @private + */ private async submitPendingOperationalEvents() { if (this.pendingOperationalEvents.length === 0) { return; @@ -95,6 +167,10 @@ export default class MetricsManager { } } + /** + * Submits all pending business events if ready. + * @private + */ private async submitPendingBusinessEvents() { if (this.pendingBusinessEvents.length === 0) { return; @@ -114,6 +190,13 @@ export default class MetricsManager { } } + /** + * Adds a duration property to the event payload if the event was timed. + * @param eventName - The name of the event. + * @param options - Optional event payload. + * @returns The event payload with duration if applicable. + * @private + */ private addDurationIfTimed(eventName: string, options?: EventPayload): EventPayload { const durationKey = 'duration_ms'; for (const [genericKey, timing] of Object.entries(this.runningEvents)) { @@ -131,10 +214,24 @@ export default class MetricsManager { return options || {}; } + /** + * Converts spaces in a string to underscores. + * @param str - The input string. + * @returns The string with spaces replaced by underscores. + * @public + * @example + * MetricsManager.spacesToUnderscore('my event name'); // 'my_event_name' + */ static spacesToUnderscore(str: string): string { return str.replace(/ /g, '_'); } + /** + * Prepares the event payload by removing empty or undefined fields and adding common metadata. + * @param obj - The original event payload. + * @returns The cleaned and enriched event payload. + * @private + */ private static preparePayload(obj: EventPayload): EventPayload { const payload: EventPayload = {}; @@ -160,11 +257,23 @@ export default class MetricsManager { return payloadWithCommonMetadata; } + /** + * Checks if metrics collection is currently disabled. + * @returns True if metrics are disabled, false otherwise. + * @private + */ private isMetricsDisabled(): boolean { // TODO: SPARK-637285 Need to return true if in development mode to avoid sending metrics to the server return this.metricsDisabled; } + /** + * Enables or disables metrics collection. Clears pending events if disabled. + * @param disabled - Whether to disable metrics. + * @public + * @example + * MetricsManager.getInstance().setMetricsDisabled(true); + */ public setMetricsDisabled(disabled: boolean) { this.metricsDisabled = disabled; if (disabled) { @@ -172,12 +281,24 @@ export default class MetricsManager { } } + /** + * Clears all pending events from the queues. + * @private + */ private clearPendingEvents() { this.pendingBehavioralEvents.length = 0; this.pendingOperationalEvents.length = 0; this.pendingBusinessEvents.length = 0; } + /** + * Tracks a behavioral event and submits it if possible. + * @param name - The metric event name. + * @param options - Optional event payload. + * @public + * @example + * MetricsManager.getInstance().trackBehavioralEvent('AGENT_LOGIN', {agentId: '123'}); + */ public trackBehavioralEvent(name: METRIC_EVENT_NAMES, options?: EventPayload) { if (this.isMetricsDisabled()) { return; @@ -191,6 +312,14 @@ export default class MetricsManager { this.submitPendingBehavioralEvents(); } + /** + * Tracks an operational event and submits it if possible. + * @param name - The metric event name. + * @param options - Optional event payload. + * @public + * @example + * MetricsManager.getInstance().trackOperationalEvent('AGENT_LOGOUT', {agentId: '123'}); + */ public trackOperationalEvent(name: METRIC_EVENT_NAMES, options?: EventPayload) { if (this.isMetricsDisabled()) { return; @@ -204,6 +333,14 @@ export default class MetricsManager { this.submitPendingOperationalEvents(); } + /** + * Tracks a business event and submits it if possible. + * @param name - The metric event name. + * @param options - Optional event payload. + * @public + * @example + * MetricsManager.getInstance().trackBusinessEvent('AGENT_TRANSFER', {agentId: '123'}); + */ public trackBusinessEvent(name: METRIC_EVENT_NAMES, options?: EventPayload) { if (this.isMetricsDisabled()) { return; @@ -217,6 +354,15 @@ export default class MetricsManager { this.submitPendingBusinessEvents(); } + /** + * Tracks an event across one or more metric services. + * @param name - The metric event name. + * @param payload - Optional event payload. + * @param metricServices - Array of metric types to track (default: ['behavioral']). + * @public + * @example + * MetricsManager.getInstance().trackEvent('AGENT_LOGIN', {agentId: '123'}, ['behavioral', 'operational']); + */ public trackEvent( name: METRIC_EVENT_NAMES, payload?: EventPayload, @@ -243,6 +389,14 @@ export default class MetricsManager { } } + /** + * Starts timing for one or more event keys. + * @param keys - A string or array of strings representing event keys. + * @public + * @example + * MetricsManager.getInstance().timeEvent('AGENT_LOGIN'); + * MetricsManager.getInstance().timeEvent(['AGENT_LOGIN', 'AGENT_LOGOUT']); + */ public timeEvent(keys: string | string[]) { if (this.isMetricsDisabled()) { return; @@ -258,6 +412,11 @@ export default class MetricsManager { this.runningEvents[genericKey] = {startTime: Date.now(), keys: new Set(keyArray)}; } + /** + * Sets the Webex SDK instance and marks the manager as ready when the SDK is ready. + * @param webex - The Webex SDK instance. + * @private + */ private setWebex(webex: WebexSDK) { this.webex = webex; if (this.webex.ready) { @@ -268,7 +427,14 @@ export default class MetricsManager { }); } - // Make the class a singleton + /** + * Returns the singleton instance of MetricsManager, initializing it if necessary. + * @param options - Optional object containing the Webex SDK instance. + * @returns The singleton MetricsManager instance. + * @public + * @example + * const metrics = MetricsManager.getInstance({webex}); + */ public static getInstance(options?: {webex: WebexSDK}): MetricsManager { if (!MetricsManager.instance) { MetricsManager.instance = new MetricsManager(); @@ -281,10 +447,24 @@ export default class MetricsManager { return MetricsManager.instance; } + /** + * Resets the singleton instance of MetricsManager. Useful for testing. + * @public + * @example + * MetricsManager.resetInstance(); + */ public static resetInstance() { MetricsManager.instance = undefined; } + /** + * Extracts common tracking fields from an AQM response object. + * @param response - The AQM response object. + * @returns An object containing common tracking fields. + * @public + * @example + * const fields = MetricsManager.getCommonTrackingFieldForAQMResponse(response); + */ public static getCommonTrackingFieldForAQMResponse(response: any): Record { // This method is used to extract common tracking fields from the AQM response // and return them as an object. The fields are extracted from the response @@ -303,6 +483,14 @@ export default class MetricsManager { return fields; } + /** + * Extracts common tracking fields from an AQM failure response object. + * @param failureResponse - The AQM failure response object. + * @returns An object containing common tracking fields for failures. + * @public + * @example + * const fields = MetricsManager.getCommonTrackingFieldForAQMResponseFailed(failureResponse); + */ public static getCommonTrackingFieldForAQMResponseFailed( failureResponse: Failure ): Record { diff --git a/packages/@webex/plugin-cc/src/metrics/behavioral-events.ts b/packages/@webex/plugin-cc/src/metrics/behavioral-events.ts index e2219c745b1..fbfb3ff8a6a 100644 --- a/packages/@webex/plugin-cc/src/metrics/behavioral-events.ts +++ b/packages/@webex/plugin-cc/src/metrics/behavioral-events.ts @@ -7,6 +7,18 @@ import { import {METRIC_EVENT_NAMES} from './constants'; import {PRODUCT_NAME} from '../constants'; +/** + * Represents the taxonomy for a behavioral event in the metrics system. + * @ignore + * @typedef BehavioralEventTaxonomy + * @property {MetricEventProduct} product - The product associated with the behavioral event. + * @property {MetricEventAgent} agent - The agent responsible for the behavioral event. + * @property {string} target - The target entity of the behavioral event. + * @property {MetricEventVerb} verb - The action or verb describing the behavioral event. + * + * @category Metrics + * @type BehavioralEvents + */ export type BehavioralEventTaxonomy = { product: MetricEventProduct; agent: MetricEventAgent; @@ -17,7 +29,27 @@ export type BehavioralEventTaxonomy = { const product: MetricEventProduct = PRODUCT_NAME; // Adding new metrics? Please add them to the Cypher CC metrics wiki - +/** + * @ignore + * @typedoc + * A mapping between metric event names and their corresponding behavioral event taxonomy definitions. + * + * This map is used to associate each event (such as login, logout, task actions, etc.) + * with a structured taxonomy object that describes the product, agent, target, and verb + * for behavioral analytics and metrics reporting. + * + * The keys are string constants from `METRIC_EVENT_NAMES`, and the values are + * `BehavioralEventTaxonomy` objects that define the event's context. + * + * @example + * ```typescript + * const taxonomy = eventTaxonomyMap[METRIC_EVENT_NAMES.STATION_LOGIN_SUCCESS]; + * // taxonomy = { product, agent: 'user', target: 'station_login', verb: 'complete' } + * ``` + * + * @see BehavioralEventTaxonomy + * @see METRIC_EVENT_NAMES + */ const eventTaxonomyMap: Record = { [METRIC_EVENT_NAMES.STATION_LOGIN_SUCCESS]: { product, @@ -287,6 +319,14 @@ const eventTaxonomyMap: Record = { }, }; +/** + * Get the taxonomy information for a given behavioral event name. + * @ignore + * @param name - The name of the metric event to look up. + * @returns The corresponding {@link BehavioralEventTaxonomy} if found, otherwise `undefined`. + * + * @typedoc + */ export function getEventTaxonomy(name: METRIC_EVENT_NAMES): BehavioralEventTaxonomy | undefined { return eventTaxonomyMap[name]; } diff --git a/packages/@webex/plugin-cc/src/metrics/constants.ts b/packages/@webex/plugin-cc/src/metrics/constants.ts index 4b060e20acc..9d8fc7a55f6 100644 --- a/packages/@webex/plugin-cc/src/metrics/constants.ts +++ b/packages/@webex/plugin-cc/src/metrics/constants.ts @@ -1,5 +1,67 @@ type Enum> = T[keyof T]; +/** + * @ignore + * @module METRIC_EVENT_NAMES + * @export + * @description + * A constant object containing all metric event names used for tracking various agent and task-related events + * within the Contact Center plugin. Each property represents a specific event and its corresponding string value + * as reported in metrics. + * + * @property {string} STATION_LOGIN_SUCCESS - Event name for successful station login. + * @property {string} STATION_LOGIN_FAILED - Event name for failed station login. + * @property {string} STATION_LOGOUT_SUCCESS - Event name for successful station logout. + * @property {string} STATION_LOGOUT_FAILED - Event name for failed station logout. + * @property {string} STATION_RELOGIN_SUCCESS - Event name for successful station relogin. + * @property {string} STATION_RELOGIN_FAILED - Event name for failed station relogin. + * @property {string} AGENT_STATE_CHANGE_SUCCESS - Event name for successful agent state change. + * @property {string} AGENT_STATE_CHANGE_FAILED - Event name for failed agent state change. + * @property {string} FETCH_BUDDY_AGENTS_SUCCESS - Event name for successfully fetching buddy agents. + * @property {string} FETCH_BUDDY_AGENTS_FAILED - Event name for failed attempt to fetch buddy agents. + * @property {string} WEBSOCKET_REGISTER_SUCCESS - Event name for successful websocket registration. + * @property {string} WEBSOCKET_REGISTER_FAILED - Event name for failed websocket registration. + * @property {string} AGENT_RONA - Event name for agent RONA (Ring No Answer). + * + * @property {string} TASK_ACCEPT_SUCCESS - Event name for successful task acceptance. + * @property {string} TASK_ACCEPT_FAILED - Event name for failed task acceptance. + * @property {string} TASK_DECLINE_SUCCESS - Event name for successful task decline. + * @property {string} TASK_DECLINE_FAILED - Event name for failed task decline. + * @property {string} TASK_END_SUCCESS - Event name for successful task end. + * @property {string} TASK_END_FAILED - Event name for failed task end. + * @property {string} TASK_WRAPUP_SUCCESS - Event name for successful task wrap-up. + * @property {string} TASK_WRAPUP_FAILED - Event name for failed task wrap-up. + * @property {string} TASK_HOLD_SUCCESS - Event name for successful task hold. + * @property {string} TASK_HOLD_FAILED - Event name for failed task hold. + * @property {string} TASK_RESUME_SUCCESS - Event name for successful task resume. + * @property {string} TASK_RESUME_FAILED - Event name for failed task resume. + * + * @property {string} TASK_CONSULT_START_SUCCESS - Event name for successful consult start. + * @property {string} TASK_CONSULT_START_FAILED - Event name for failed consult start. + * @property {string} TASK_CONSULT_END_SUCCESS - Event name for successful consult end. + * @property {string} TASK_CONSULT_END_FAILED - Event name for failed consult end. + * @property {string} TASK_TRANSFER_SUCCESS - Event name for successful task transfer. + * @property {string} TASK_TRANSFER_FAILED - Event name for failed task transfer. + * @property {string} TASK_RESUME_RECORDING_SUCCESS - Event name for successful resume of recording. + * @property {string} TASK_RESUME_RECORDING_FAILED - Event name for failed resume of recording. + * @property {string} TASK_PAUSE_RECORDING_SUCCESS - Event name for successful pause of recording. + * @property {string} TASK_PAUSE_RECORDING_FAILED - Event name for failed pause of recording. + * @property {string} TASK_ACCEPT_CONSULT_SUCCESS - Event name for successful consult acceptance. + * @property {string} TASK_ACCEPT_CONSULT_FAILED - Event name for failed consult acceptance. + * + * @property {string} TASK_OUTDIAL_SUCCESS - Event name for successful outdial task. + * @property {string} TASK_OUTDIAL_FAILED - Event name for failed outdial task. + * + * @property {string} UPLOAD_LOGS_SUCCESS - Event name for successful log upload. + * @property {string} UPLOAD_LOGS_FAILED - Event name for failed log upload. + * @property {string} WEBSOCKET_DEREGISTER_SUCCESS - Event name for successful websocket deregistration. + * @property {string} WEBSOCKET_DEREGISTER_FAIL - Event name for failed websocket deregistration. + * + * @property {string} AGENT_DEVICE_TYPE_UPDATE_SUCCESS - Event name for successful agent device type update. + * @property {string} AGENT_DEVICE_TYPE_UPDATE_FAILED - Event name for failed agent device type update. + * + * @readonly + */ export const METRIC_EVENT_NAMES = { STATION_LOGIN_SUCCESS: 'Station Login Success', STATION_LOGIN_FAILED: 'Station Login Failed', @@ -55,5 +117,15 @@ export const METRIC_EVENT_NAMES = { AGENT_DEVICE_TYPE_UPDATE_FAILED: 'Agent Device Type Update Failed', } as const; -// Derive the type using the utility type +/** + * Represents the possible metric event names used within the metrics system. + * + * This type is derived from the keys of the `METRIC_EVENT_NAMES` constant, ensuring + * type safety and consistency when referring to metric event names throughout the codebase. + * @export + * @typedef {Enum} METRIC_EVENT_NAMES + * @typeParam T - The type of the `METRIC_EVENT_NAMES` constant. + * + * @see {@link METRIC_EVENT_NAMES} + */ export type METRIC_EVENT_NAMES = Enum; diff --git a/packages/@webex/plugin-cc/src/services/WebCallingService.ts b/packages/@webex/plugin-cc/src/services/WebCallingService.ts index 248c94fe321..b7641f4b870 100644 --- a/packages/@webex/plugin-cc/src/services/WebCallingService.ts +++ b/packages/@webex/plugin-cc/src/services/WebCallingService.ts @@ -18,42 +18,104 @@ import { POST_AUTH, WCC_CALLING_RTMS_DOMAIN, DEREGISTER_WEBCALLING_LINE_MSG, + METHODS, } from './constants'; +/** + * WebCallingService provides WebRTC calling functionality for Contact Center agents. + * It handles registration, call management, and media operations for voice interactions. + * @internal + */ export default class WebCallingService extends EventEmitter { + /** + * The CallingClient instance that manages WebRTC calling capabilities + * @private + */ private callingClient: ICallingClient; + + /** + * The Line instance that handles registration and incoming calls + * @private + */ private line: ILine; + + /** + * The current active call instance + * @private + */ private call: ICall | undefined; + + /** + * Reference to the WebexSDK instance + * @private + */ private webex: WebexSDK; + + /** + * The login option selected for this session + * @private + */ public loginOption: LoginOption; + + /** + * Map that associates call IDs with task IDs for correlation + * @private + */ private callTaskMap: Map; + /** + * Creates an instance of WebCallingService. + * @param {WebexSDK} webex - The Webex SDK instance + */ constructor(webex: WebexSDK) { super(); this.webex = webex; this.callTaskMap = new Map(); } - public setLoginOption(loginOption: LoginOption) { + /** + * Sets the login option for the current session + * @param {LoginOption} loginOption - The login option to use + * @private + */ + public setLoginOption(loginOption: LoginOption): void { this.loginOption = loginOption; } - private handleMediaEvent = (track: MediaStreamTrack) => { + /** + * Handles remote media track events from the call + * @param {MediaStreamTrack} track - The media track received + * @private + */ + private handleMediaEvent = (track: MediaStreamTrack): void => { this.emit(CALL_EVENT_KEYS.REMOTE_MEDIA, track); }; - private handleDisconnectEvent = () => { + /** + * Handles disconnect events from the call + * @private + */ + private handleDisconnectEvent = (): void => { this.call.end(); this.cleanUpCall(); }; - private registerCallListeners() { + /** + * Registers event listeners for the current call + * @private + */ + private registerCallListeners(): void { // TODO: Add remaining call listeners here this.call.on(CALL_EVENT_KEYS.REMOTE_MEDIA, this.handleMediaEvent); this.call.on(CALL_EVENT_KEYS.DISCONNECT, this.handleDisconnectEvent); } - public cleanUpCall() { + /** + * Cleans up resources associated with the current call + * Removes event listeners and clears the call-task mapping + * @private + */ + public cleanUpCall(): void { if (this.call) { this.call.off(CALL_EVENT_KEYS.REMOTE_MEDIA, this.handleMediaEvent); this.call.off(CALL_EVENT_KEYS.DISCONNECT, this.handleDisconnectEvent); @@ -67,7 +129,13 @@ export default class WebCallingService extends EventEmitter { } } - private async getRTMSDomain() { + /** + * Retrieves the RTMS domain to use for WebRTC connections + * First tries to get it from the service catalog, then falls back to default + * @private + * @returns {Promise} The RTMS domain to use + */ + private async getRTMSDomain(): Promise { await this.webex.internal.services.waitForCatalog(POST_AUTH); const rtmsURL = this.webex.internal.services.get(WCC_CALLING_RTMS_DOMAIN); @@ -81,6 +149,7 @@ export default class WebCallingService extends EventEmitter { `Invalid URL from u2c catalogue: ${rtmsURL} so falling back to default domain`, { module: WEB_CALLING_SERVICE_FILE, + method: METHODS.GET_RTMS_DOMAIN, } ); @@ -88,6 +157,14 @@ export default class WebCallingService extends EventEmitter { } } + /** + * Registers the WebCalling line for receiving calls + * Sets up event listeners for line events and initializes the calling client + * + * @private + * @returns {Promise} A promise that resolves when registration is complete + * @throws {Error} When registration times out + */ public async registerWebCallingLine(): Promise { const rtmsDomain = await this.getRTMSDomain(); // get the RTMS domain from the u2c catalogue @@ -107,7 +184,7 @@ export default class WebCallingService extends EventEmitter { this.line.on(LINE_EVENTS.UNREGISTERED, () => { LoggerProxy.log(`WxCC-SDK: Desktop unregistered successfully`, { module: WEB_CALLING_SERVICE_FILE, - method: this.registerWebCallingLine.name, + method: METHODS.REGISTER_WEB_CALLING_LINE, }); }); @@ -126,7 +203,7 @@ export default class WebCallingService extends EventEmitter { clearTimeout(timeout); LoggerProxy.log( `WxCC-SDK: Desktop registered successfully, mobiusDeviceId: ${deviceInfo.mobiusDeviceId}`, - {module: WEB_CALLING_SERVICE_FILE, method: this.registerWebCallingLine.name} + {module: WEB_CALLING_SERVICE_FILE, method: METHODS.REGISTER_WEB_CALLING_LINE} ); resolve(); }); @@ -134,41 +211,83 @@ export default class WebCallingService extends EventEmitter { }); } - public async deregisterWebCallingLine() { + /** + * Deregisters the WebCalling line + * Cleans up any active calls and deregisters from the calling service + * + * @private + * @returns {Promise} A promise that resolves when deregistration is complete + */ + public async deregisterWebCallingLine(): Promise { LoggerProxy.log(DEREGISTER_WEBCALLING_LINE_MSG, { module: WEB_CALLING_SERVICE_FILE, - method: 'deregisterWebCallingLine', + method: METHODS.DEREGISTER_WEB_CALLING_LINE, }); this.cleanUpCall(); this.line?.deregister(); } - public answerCall(localAudioStream: LocalMicrophoneStream, taskId: string) { + /** + * Answers an incoming call with the provided audio stream + * + * @private + * @param {LocalMicrophoneStream} localAudioStream - The local microphone stream to use + * @param {string} taskId - The task ID associated with this call + * @throws {Error} If answering the call fails + */ + public answerCall(localAudioStream: LocalMicrophoneStream, taskId: string): void { if (this.call) { try { - this.webex.logger.info(`Call answered: ${taskId}`); + LoggerProxy.info(`Call answered: ${taskId}`, { + module: WEB_CALLING_SERVICE_FILE, + method: METHODS.ANSWER_CALL, + }); this.call.answer(localAudioStream); this.registerCallListeners(); } catch (error) { - this.webex.logger.error(`Failed to answer call for ${taskId}. Error: ${error}`); + LoggerProxy.error(`Failed to answer call for ${taskId}. Error: ${error}`, { + module: WEB_CALLING_SERVICE_FILE, + method: METHODS.ANSWER_CALL, + }); // Optionally, throw the error to allow the invoker to handle it throw error; } } else { - this.webex.logger.log(`Cannot answer a non WebRtc Call: ${taskId}`); + LoggerProxy.log(`Cannot answer a non WebRtc Call: ${taskId}`, { + module: WEB_CALLING_SERVICE_FILE, + method: METHODS.ANSWER_CALL, + }); } } - public muteUnmuteCall(localAudioStream: LocalMicrophoneStream) { + /** + * Toggles the mute state of the current call + * + * @private + * @param {LocalMicrophoneStream} localAudioStream - The local microphone stream to control + */ + public muteUnmuteCall(localAudioStream: LocalMicrophoneStream): void { if (this.call) { - this.webex.logger.info('Call mute or unmute requested!'); + LoggerProxy.info('Call mute or unmute requested!', { + module: WEB_CALLING_SERVICE_FILE, + method: METHODS.MUTE_UNMUTE_CALL, + }); this.call.mute(localAudioStream); } else { - this.webex.logger.log(`Cannot mute a non WebRtc Call`); + LoggerProxy.log(`Cannot mute a non WebRtc Call`, { + module: WEB_CALLING_SERVICE_FILE, + method: METHODS.MUTE_UNMUTE_CALL, + }); } } - public isCallMuted() { + /** + * Checks if the current call is muted + * + * @private + * @returns {boolean} True if the call is muted, false otherwise or if no call exists + */ + public isCallMuted(): boolean { if (this.call) { return this.call.isMuted(); } @@ -176,26 +295,56 @@ export default class WebCallingService extends EventEmitter { return false; } - public declineCall(taskId: string) { + /** + * Declines or ends the current call + * + * @private + * @param {string} taskId - The task ID associated with this call + * @throws {Error} If ending the call fails + */ + public declineCall(taskId: string): void { if (this.call) { try { - this.webex.logger.info(`Call end requested: ${taskId}`); + LoggerProxy.info(`Call end requested: ${taskId}`, { + module: WEB_CALLING_SERVICE_FILE, + method: METHODS.DECLINE_CALL, + }); this.call.end(); this.cleanUpCall(); } catch (error) { - this.webex.logger.error(`Failed to end call: ${taskId}. Error: ${error}`); + LoggerProxy.error(`Failed to end call: ${taskId}. Error: ${error}`, { + module: WEB_CALLING_SERVICE_FILE, + method: METHODS.DECLINE_CALL, + }); // Optionally, throw the error to allow the invoker to handle it throw error; } } else { - this.webex.logger.log(`Cannot end a non WebRtc Call: ${taskId}`); + LoggerProxy.log(`Cannot end a non WebRtc Call: ${taskId}`, { + module: WEB_CALLING_SERVICE_FILE, + method: METHODS.DECLINE_CALL, + }); } } - public mapCallToTask(callId: string, taskId: string) { + /** + * Maps a call ID to a task ID for correlation + * + * @private + * @param {string} callId - The unique call identifier + * @param {string} taskId - The associated task identifier + */ + public mapCallToTask(callId: string, taskId: string): void { this.callTaskMap.set(callId, taskId); } + /** + * Gets the task ID associated with a call ID + * + * @private + * @param {string} callId - The call ID to look up + * @returns {string|undefined} The associated task ID or undefined if not found + */ public getTaskIdForCall(callId: string): string | undefined { return this.callTaskMap.get(callId); } diff --git a/packages/@webex/plugin-cc/src/services/agent/index.ts b/packages/@webex/plugin-cc/src/services/agent/index.ts index 97d9ae36fbb..ff93314875c 100644 --- a/packages/@webex/plugin-cc/src/services/agent/index.ts +++ b/packages/@webex/plugin-cc/src/services/agent/index.ts @@ -6,14 +6,17 @@ import {HTTP_METHODS} from '../../types'; import {WCC_API_GATEWAY} from '../constants'; import {CC_EVENTS} from '../config/types'; -/* - * routingAgent - * @param reqs - * @category Routing Service +/** + * Agent Service provides methods to manage agent states and operations + * @param routing - AqmReqs instance for making API requests + * @ignore */ - export default function routingAgent(routing: AqmReqs) { return { + /** + * Reloads the agent session + * @public + */ reload: routing.reqEmpty(() => ({ host: WCC_API_GATEWAY, url: '/v1/agents/reload', @@ -34,6 +37,11 @@ export default function routingAgent(routing: AqmReqs) { errId: 'Service.aqm.agent.reload', }, })), + /** + * Logs out the agent + * @param p.data - Logout parameters + * @public + */ logout: routing.req((p: {data: Agent.Logout}) => ({ url: '/v1/agents/logout', host: WCC_API_GATEWAY, @@ -54,6 +62,11 @@ export default function routingAgent(routing: AqmReqs) { errId: 'Service.aqm.agent.logout', }, })), + /** + * Logs in the agent to a station + * @param p.data - Station login parameters + * @public + */ stationLogin: routing.req((p: {data: Agent.UserStationLogin}) => ({ url: '/v1/agents/login', host: WCC_API_GATEWAY, @@ -80,6 +93,11 @@ export default function routingAgent(routing: AqmReqs) { errId: 'Service.aqm.agent.stationLoginFailed', }, })), + /** + * Changes the agent's state + * @param p.data - State change parameters + * @public + */ stateChange: routing.req((p: {data: Agent.StateChange}) => ({ url: '/v1/agents/session/state', host: WCC_API_GATEWAY, @@ -101,6 +119,11 @@ export default function routingAgent(routing: AqmReqs) { errId: 'Service.aqm.agent.stateChange', }, })), + /** + * Retrieves list of buddy agents + * @param p.data - Buddy agent query parameters + * @public + */ buddyAgents: routing.req((p: {data: Agent.BuddyAgents}) => ({ url: `/v1/agents/buddyList`, host: WCC_API_GATEWAY, diff --git a/packages/@webex/plugin-cc/src/services/agent/types.ts b/packages/@webex/plugin-cc/src/services/agent/types.ts index 8ac1730e0eb..124e9f68686 100644 --- a/packages/@webex/plugin-cc/src/services/agent/types.ts +++ b/packages/@webex/plugin-cc/src/services/agent/types.ts @@ -1,153 +1,357 @@ import {Msg} from '../core/GlobalTypes'; +/** + * Response type received when an agent successfully logs out from the system + * @public + * @remarks + * This type represents the response message sent by the server when an agent + * successfully logs out. It includes essential details about the logout action + * and the agent's final state. + */ export type LogoutSuccess = Msg<{ + /** Message type identifier for agent desktop events */ eventType: 'AgentDesktopMessage'; + /** Unique identifier of the agent */ agentId: string; + /** Tracking ID for the logout request */ trackingId: string; + /** Current session ID of the agent */ agentSessionId: string; + /** Organization ID the agent belongs to */ orgId: string; + /** Current status of the agent (e.g., 'LoggedOut') */ status: string; + /** Detailed status information */ subStatus: string; + /** Identity of who initiated the logout if not the agent themselves */ loggedOutBy?: string; + /** List of roles assigned to the agent */ roles?: string[]; + /** Type identifier for logout success event */ type: 'AgentLogoutSuccess'; }>; +/** + * Response type received when an agent successfully relogins to the system + * @public + * @remarks + * Represents the response message when an agent successfully re-authenticates. + * Contains comprehensive information about the agent's new session, including + * their state, assigned channels, and device information. + */ export type ReloginSuccess = Msg<{ + /** Message type identifier for agent desktop events */ eventType: 'AgentDesktopMessage'; + /** Unique identifier of the agent */ agentId: string; + /** Tracking ID for the relogin request */ trackingId: string; + /** Auxiliary code ID for the agent's initial state */ auxCodeId: string; + /** ID of the team the agent belongs to */ teamId: string; + /** New session ID assigned to the agent */ agentSessionId: string; + /** Directory number assigned to the agent */ dn: string; + /** Organization ID the agent belongs to */ orgId: string; + /** List of active interaction IDs */ interactionIds: string[]; + /** Indicates if login is via extension */ isExtension: boolean; + /** Current login status */ status: 'LoggedIn'; + /** Current sub-status */ subStatus: 'Idle'; + /** ID of the site where the agent is located */ siteId: string; + /** Timestamp of last idle code change */ lastIdleCodeChangeTimestamp: number; + /** Timestamp of last state change */ lastStateChangeTimestamp: number; + /** Reason for the last state change */ lastStateChangeReason?: string; + /** Type of agent profile */ profileType: string; + /** Map of channel types to channel IDs */ channelsMap: Record; + /** Phone number for dialing */ dialNumber?: string; + /** List of roles assigned to the agent */ roles?: string[]; + /** Type of device being used */ deviceType?: DeviceType; + /** Unique identifier of the device */ deviceId?: string | null; + /** Flag indicating if emergency modal was shown */ isEmergencyModalAlreadyDisplayed?: boolean; + /** Type identifier for relogin success event */ type: 'AgentReloginSuccess'; }>; +/** + * Response type received when an agent's state is successfully changed + * @public + * @remarks + * Contains information about the agent's new state, including who initiated + * the change and timestamps for tracking state transitions. + */ export type StateChangeSuccess = Msg<{ + /** Message type identifier for agent desktop events */ eventType: 'AgentDesktopMessage'; + /** Unique identifier of the agent */ agentId: string; + /** Tracking ID for the state change request */ trackingId: string; + /** Auxiliary code ID associated with the new state */ auxCodeId: string; + /** Current session ID of the agent */ agentSessionId: string; + /** Organization ID the agent belongs to */ orgId: string; + /** Current status of the agent */ status: string; + /** Detailed status indicating availability */ subStatus: 'Available' | 'Idle'; + /** Timestamp of last idle code change */ lastIdleCodeChangeTimestamp: number; + /** Timestamp of current state change */ lastStateChangeTimestamp: number; + /** Type identifier for state change success event */ type: 'AgentStateChangeSuccess'; + /** Identity of who initiated the state change */ changedBy: string | null; + /** ID of the user who initiated the change */ changedById: string | null; + /** Name of the user who initiated the change */ changedByName: string | null; + /** Reason for the state change */ lastStateChangeReason: string; }>; +/** + * Response type received when an agent successfully logs into their station + * @public + * @remarks + * Represents the success response when an agent logs into their workstation. + * Includes details about the agent's initial state, assigned teams, and channels. + */ export type StationLoginSuccess = Msg<{ + /** Message type identifier for agent desktop events */ eventType: 'AgentDesktopMessage'; + /** Unique identifier of the agent */ agentId: string; + /** Tracking ID for the station login request */ trackingId: string; + /** Auxiliary code ID for initial state */ auxCodeId: string; + /** ID of the team the agent belongs to */ teamId: string; + /** New session ID assigned to the agent */ agentSessionId: string; + /** Organization ID the agent belongs to */ orgId: string; + /** List of active interaction IDs */ interactionIds: string[]; + /** Current login status */ status: string; + /** Current availability status */ subStatus: 'Available' | 'Idle'; + /** ID of the site where the agent is located */ siteId: string; + /** Timestamp of last idle code change */ lastIdleCodeChangeTimestamp: number; + /** Timestamp of last state change */ lastStateChangeTimestamp: number; + /** Type of agent profile */ profileType: string; + /** Map of channel types to channel IDs */ channelsMap: Record; + /** Phone number for dialing */ dialNumber?: string; + /** List of roles assigned to the agent */ roles?: string[]; + /** Session ID of the supervising agent if applicable */ supervisorSessionId?: string; + /** Type identifier for station login success event */ type: 'AgentStationLoginSuccess'; }>; +/** + * Extended response type for station login success that includes notification tracking + * @public + * @remarks + * Similar to StationLoginSuccess but includes additional fields for notification + * tracking and multimedia profile settings. + */ export type StationLoginSuccessResponse = { + /** Message type identifier for agent desktop events */ eventType: 'AgentDesktopMessage'; + /** Unique identifier of the agent */ agentId: string; + /** Tracking ID for the station login request */ trackingId: string; + /** Auxiliary code ID for initial state */ auxCodeId: string; + /** ID of the team the agent belongs to */ teamId: string; + /** New session ID assigned to the agent */ agentSessionId: string; + /** Organization ID the agent belongs to */ orgId: string; + /** List of active interaction IDs */ interactionIds: string[]; + /** Current login status */ status: string; + /** Current availability status */ subStatus: 'Available' | 'Idle'; + /** ID of the site where the agent is located */ siteId: string; + /** Timestamp of last idle code change */ lastIdleCodeChangeTimestamp: number; + /** Timestamp of last state change */ lastStateChangeTimestamp: number; + /** Type of agent profile */ profileType: string; + /** Multimedia profile capacity settings */ mmProfile: { + /** Maximum concurrent chat capacity */ chat: number; + /** Maximum concurrent email capacity */ email: number; + /** Maximum concurrent social media capacity */ social: number; + /** Maximum concurrent voice call capacity */ telephony: number; }; + /** Phone number for dialing */ dialNumber?: string; + /** List of roles assigned to the agent */ roles?: string[]; + /** Session ID of the supervising agent if applicable */ supervisorSessionId?: string; + /** Type identifier for station login success event */ type: 'AgentStationLoginSuccess'; + /** Tracking ID for notifications */ notifsTrackingId: string; }; +/** + * Extended response type for agent device type update success + * @public + * @remarks + * Represents the response when an agent's device type is successfully updated. + * Contains all the details of the agent's session and device configuration. + */ export type DeviceTypeUpdateSuccess = Omit & { type: 'AgentDeviceTypeUpdateSuccess'; }; - +/** + * Parameters required for initiating an agent logout + * @public + * @remarks + * Defines the parameters that can be provided when logging out an agent, + * including the reason for logout which helps with reporting and auditing. + */ export type Logout = { + /** Reason for the logout action */ logoutReason?: | 'User requested logout' | 'Inactivity Logout' | 'User requested agent device change'; }; +/** + * Represents the possible states an agent can be in + * @public + * @remarks + * Defines the various states an agent can transition between during their session. + * Common states include 'Available' (ready to handle interactions), 'Idle' (on break + * or not ready), and 'RONA' (Response on No Answer). + */ export type AgentState = 'Available' | 'Idle' | 'RONA' | string; +/** + * Parameters required for changing an agent's state + * @public + * @remarks + * Defines the necessary information for transitioning an agent from one state to another. + */ export type StateChange = { + /** New state to transition the agent to */ state: AgentState; + /** Auxiliary code ID associated with the state change */ auxCodeId: string; + /** Reason for the state change */ lastStateChangeReason?: string; + /** ID of the agent whose state is being changed */ agentId?: string; }; +/** + * Parameters required for agent station login + * @public + * @remarks + * Contains all the necessary information for logging an agent into their workstation, + * including team assignments, roles, and device configurations. + */ export type UserStationLogin = { + /** Phone number for dialing */ dialNumber?: string | null; + /** Directory number */ dn?: string | null; + /** ID of the team the agent belongs to */ teamId: string | null; + /** Name of the team */ teamName: string | null; + /** List of roles assigned to the agent */ roles?: Array; + /** ID of the site where the agent is located */ siteId: string; + /** Indicates if agent uses a different DN than their assigned one */ usesOtherDN: boolean; + /** ID of the agent's skill profile */ skillProfileId?: string; + /** ID of the initial auxiliary state code */ auxCodeId: string; + /** Indicates if login is via extension */ isExtension?: boolean; + /** Type of device being used */ deviceType?: DeviceType; + /** Unique identifier of the device */ deviceId?: string | null; + /** Flag indicating if emergency modal was shown */ isEmergencyModalAlreadyDisplayed?: boolean; }; +/** + * Available options for agent login methods + * @public + * @remarks + * Defines the supported methods for agent login: + * - AGENT_DN: Login using agent's direct number + * - EXTENSION: Login using extension number + * - BROWSER: Browser-based login + */ export type LoginOption = 'AGENT_DN' | 'EXTENSION' | 'BROWSER'; +/** + * Type of device used for agent login + * @public + * @remarks + * Represents the type of device being used for login. Can be one of the standard + * LoginOptions or a custom device type string. + */ export type DeviceType = LoginOption | string; +/** + * Parameters for retrieving buddy agent information + * @public + * @remarks + * Defines the criteria for fetching information about other agents (buddies) + * in the system, allowing filtering by profile, media type, and state. + */ export type BuddyAgents = { agentProfileId: string; mediaType: string; @@ -155,6 +359,13 @@ export type BuddyAgents = { state?: string; }; +/** + * Detailed information about a buddy agent + * @public + * @remarks + * Contains comprehensive information about a buddy agent including their + * current state, assignments, and contact information. + */ export type BuddyDetails = { agentId: string; state: string; @@ -164,25 +375,66 @@ export type BuddyDetails = { siteId: string; }; +/** + * Response type received when successfully retrieving buddy agent information + * @public + * @remarks + * Contains the list of buddy agents and their details returned from a buddy + * agent lookup request. Used for monitoring team member statuses and availability. + */ export type BuddyAgentsSuccess = Msg<{ + /** Message type identifier for agent desktop events */ eventType: 'AgentDesktopMessage'; + /** Unique identifier of the requesting agent */ agentId: string; + /** Tracking ID for the buddy list request */ trackingId: string; + /** Current session ID of the requesting agent */ agentSessionId: string; + /** Organization ID the agent belongs to */ orgId: string; + /** Type identifier for buddy agents response */ type: 'BuddyAgents'; + /** List of buddy agents and their details */ agentList: Array; }>; +/** + * Events emitted by the agent service for various state changes and actions + * @public + * @remarks + * Enumeration of all possible events that can be emitted by the agent service. + * These events can be used to track and respond to changes in agent state, + * login status, and other important agent-related activities. + */ export enum AGENT_EVENTS { + /** Emitted when an agent's state changes (e.g., Available to Idle) */ AGENT_STATE_CHANGE = 'agent:stateChange', + + /** Emitted when multiple logins are detected for the same agent */ AGENT_MULTI_LOGIN = 'agent:multiLogin', + + /** Emitted when an agent successfully logs into their station */ AGENT_STATION_LOGIN_SUCCESS = 'agent:stationLoginSuccess', + + /** Emitted when station login attempt fails */ AGENT_STATION_LOGIN_FAILED = 'agent:stationLoginFailed', + + /** Emitted when an agent successfully logs out */ AGENT_LOGOUT_SUCCESS = 'agent:logoutSuccess', + + /** Emitted when logout attempt fails */ AGENT_LOGOUT_FAILED = 'agent:logoutFailed', + + /** Emitted when an agent's directory number is successfully registered */ AGENT_DN_REGISTERED = 'agent:dnRegistered', + + /** Emitted when an agent successfully re-authenticates */ AGENT_RELOGIN_SUCCESS = 'agent:reloginSuccess', + + /** Emitted when agent state change is successful */ AGENT_STATE_CHANGE_SUCCESS = 'agent:stateChangeSuccess', + + /** Emitted when agent state change attempt fails */ AGENT_STATE_CHANGE_FAILED = 'agent:stateChangeFailed', } diff --git a/packages/@webex/plugin-cc/src/services/config/constants.ts b/packages/@webex/plugin-cc/src/services/config/constants.ts index 71d886760d2..d9e4ab477eb 100644 --- a/packages/@webex/plugin-cc/src/services/config/constants.ts +++ b/packages/@webex/plugin-cc/src/services/config/constants.ts @@ -1,9 +1,46 @@ // making query params configurable for List Teams and List Aux Codes API export const DEFAULT_PAGE = 0; + +/** + * Default page size for paginated API requests. + * @type {number} + * @public + * @example + * const pageSize = DEFAULT_PAGE_SIZE; // 100 + * @ignore + */ export const DEFAULT_PAGE_SIZE = 100; + +/** + * Agent state ID for 'Available'. + * @type {string} + * @public + * @ignore + */ export const AGENT_STATE_AVAILABLE_ID = '0'; + +/** + * Agent state label for 'Available'. + * @type {string} + * @public + * @ignore + */ export const AGENT_STATE_AVAILABLE = 'Available'; + +/** + * Description for the 'Available' agent state. + * @type {string} + * @public + * @ignore + */ export const AGENT_STATE_AVAILABLE_DESCRIPTION = 'Agent is available to receive calls'; + +/** + * Default attributes for auxiliary code API requests. + * @type {string[]} + * @public + * @ignore + */ export const DEFAULT_AUXCODE_ATTRIBUTES = [ 'id', 'isSystemCode', @@ -12,17 +49,113 @@ export const DEFAULT_AUXCODE_ATTRIBUTES = [ 'workTypeCode', 'active', ]; + +// Method names for config services +export const METHODS = { + // AgentConfigService methods + GET_AGENT_CONFIG: 'getAgentConfig', + GET_USER_USING_CI: 'getUserUsingCI', + GET_DESKTOP_PROFILE_BY_ID: 'getDesktopProfileById', + GET_MULTIMEDIA_PROFILE_BY_ID: 'getMultimediaProfileById', + GET_LIST_OF_TEAMS: 'getListOfTeams', + GET_ALL_TEAMS: 'getAllTeams', + GET_LIST_OF_AUX_CODES: 'getListOfAuxCodes', + GET_ALL_AUX_CODES: 'getAllAuxCodes', + GET_SITE_INFO: 'getSiteInfo', + GET_ORG_INFO: 'getOrgInfo', + GET_ORGANIZATION_SETTING: 'getOrganizationSetting', + GET_TENANT_DATA: 'getTenantData', + GET_URL_MAPPING: 'getURLMapping', + GET_DIAL_PLAN_DATA: 'getDialPlanData', + GET_QUEUES: 'getQueues', + + // Util methods + PARSE_AGENT_CONFIGS: 'parseAgentConfigs', + GET_URL_MAPPING_UTIL: 'getUrlMapping', + GET_MSFT_CONFIG: 'getMsftConfig', + GET_WEBEX_CONFIG: 'getWebexConfig', + GET_DEFAULT_AGENT_DN: 'getDefaultAgentDN', + GET_FILTERED_DIALPLAN_ENTRIES: 'getFilteredDialplanEntries', + GET_FILTER_AUX_CODES: 'getFilterAuxCodes', + GET_DEFAULT_WRAP_UP_CODE: 'getDefaultWrapUpCode', +}; + +/** + * Maps API endpoint names to functions that generate endpoint URLs for various organization resources. + * @public + * @example + * const url = endPointMap.userByCI('org123', 'agent456'); + */ export const endPointMap = { + /** + * Gets the endpoint for a user by CI user ID. + * @param orgId - Organization ID. + * @param agentId - Agent ID. + * @returns The endpoint URL string. + * @public + * @example + * const url = endPointMap.userByCI('org123', 'agent456'); + * @ignore + */ userByCI: (orgId: string, agentId: string) => `organization/${orgId}/user/by-ci-user-id/${agentId}`, + + /** + * Gets the endpoint for a desktop profile. + * @param orgId - Organization ID. + * @param desktopProfileId - Desktop profile ID. + * @returns The endpoint URL string. + * @public + * @example + * const url = endPointMap.desktopProfile('org123', 'profile789'); + * @ignore + */ desktopProfile: (orgId: string, desktopProfileId: string) => `organization/${orgId}/agent-profile/${desktopProfileId}`, + + /** + * Gets the endpoint for a multimedia profile. + * @param orgId - Organization ID. + * @param multimediaProfileId - Multimedia profile ID. + * @returns The endpoint URL string. + * @public + * @example + * const url = endPointMap.multimediaProfile('org123', 'multi456'); + * @ignore + */ multimediaProfile: (orgId: string, multimediaProfileId: string) => `organization/${orgId}/multimedia-profile/${multimediaProfileId}`, + + /** + * Gets the endpoint for listing teams with optional filters. + * @param orgId - Organization ID. + * @param page - Page number. + * @param pageSize - Page size. + * @param filter - Array of team IDs to filter. + * @returns The endpoint URL string. + * @public + * @example + * const url = endPointMap.listTeams('org123', 0, 100, ['team1', 'team2']); + * @ignore + */ listTeams: (orgId: string, page: number, pageSize: number, filter: string[]) => `organization/${orgId}/v2/team?page=${page}&pageSize=${pageSize}${ filter && filter.length > 0 ? `&filter=id=in=(${filter})` : '' }`, + + /** + * Gets the endpoint for listing auxiliary codes with optional filters and attributes. + * @param orgId - Organization ID. + * @param page - Page number. + * @param pageSize - Page size. + * @param filter - Array of auxiliary code IDs to filter. + * @param attributes - Array of attribute names to include. + * @returns The endpoint URL string. + * @public + * @example + * const url = endPointMap.listAuxCodes('org123', 0, 100, ['aux1'], ['id', 'name']); + * @ignore + */ listAuxCodes: ( orgId: string, page: number, @@ -33,12 +166,84 @@ export const endPointMap = { `organization/${orgId}/v2/auxiliary-code?page=${page}&pageSize=${pageSize}${ filter && filter.length > 0 ? `&filter=id=in=(${filter})` : '' }&attributes=${attributes}`, + + /** + * Gets the endpoint for organization info. + * @param orgId - Organization ID. + * @returns The endpoint URL string. + * @public + * @example + * const url = endPointMap.orgInfo('org123'); + * @ignore + */ orgInfo: (orgId: string) => `organization/${orgId}`, + + /** + * Gets the endpoint for organization settings. + * @param orgId - Organization ID. + * @returns The endpoint URL string. + * @public + * @example + * const url = endPointMap.orgSettings('org123'); + * @ignore + */ orgSettings: (orgId: string) => `organization/${orgId}/v2/organization-setting?agentView=true`, + + /** + * Gets the endpoint for site info. + * @param orgId - Organization ID. + * @param siteId - Site ID. + * @returns The endpoint URL string. + * @public + * @example + * const url = endPointMap.siteInfo('org123', 'site456'); + * @ignore + */ siteInfo: (orgId: string, siteId: string) => `organization/${orgId}/site/${siteId}`, + + /** + * Gets the endpoint for tenant configuration data. + * @param orgId - Organization ID. + * @returns The endpoint URL string. + * @public + * @example + * const url = endPointMap.tenantData('org123'); + * @ignore + */ tenantData: (orgId: string) => `organization/${orgId}/v2/tenant-configuration?agentView=true`, + + /** + * Gets the endpoint for organization URL mapping. + * @param orgId - Organization ID. + * @returns The endpoint URL string. + * @public + * @example + * const url = endPointMap.urlMapping('org123'); + * @ignore + */ urlMapping: (orgId: string) => `organization/${orgId}/v2/org-url-mapping?sort=name,ASC`, + + /** + * Gets the endpoint for dial plan. + * @param orgId - Organization ID. + * @returns The endpoint URL string. + * @public + * @example + * const url = endPointMap.dialPlan('org123'); + * @ignore + */ dialPlan: (orgId: string) => `organization/${orgId}/dial-plan?agentView=true`, + + /** + * Gets the endpoint for the queue list with custom query parameters. + * @param orgId - Organization ID. + * @param queryParams - Query parameters string. + * @returns The endpoint URL string. + * @public + * @example + * const url = endPointMap.queueList('org123', 'page=0&pageSize=10'); + * @ignore + */ queueList: (orgId: string, queryParams: string) => `organization/${orgId}/v2/contact-service-queue?${queryParams}`, }; diff --git a/packages/@webex/plugin-cc/src/services/config/index.ts b/packages/@webex/plugin-cc/src/services/config/index.ts index 2d29af10deb..9c71b49c5a4 100644 --- a/packages/@webex/plugin-cc/src/services/config/index.ts +++ b/packages/@webex/plugin-cc/src/services/config/index.ts @@ -1,3 +1,8 @@ +/** + * @packageDocumentation + * @module AgentConfigService + */ + import {HTTP_METHODS} from '../../types'; import LoggerProxy from '../../logger-proxy'; import { @@ -26,11 +31,14 @@ import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, endPointMap, + METHODS, } from './constants'; -/* -The AgentConfigService class provides methods to fetch agent configuration data. -*/ +/** + * The AgentConfigService class provides methods to fetch agent configuration data. + * @private + * @ignore + */ export default class AgentConfigService { private webexReq: WebexRequest; constructor() { @@ -39,9 +47,11 @@ export default class AgentConfigService { /** * Fetches the agent configuration data for the given orgId and agentId. - * @param {string} orgId - * @param {string} agentId - * @returns {Promise} + * @param {string} orgId - organization ID for which the agent configuration is to be fetched. + * @param {string} agentId - agent ID for which the configuration is to be fetched. + * @returns {Promise} - A promise that resolves to the agent configuration profile. + * @throws {Error} - Throws an error if any API call fails or if the response status is not 200. + * @public */ public async getAgentConfig(orgId: string, agentId: string): Promise { try { @@ -58,7 +68,10 @@ export default class AgentConfigService { ); const userConfigData = await userConfigPromise; - LoggerProxy.info('Fetched user data', {module: CONFIG_FILE_NAME, method: 'getAgentConfig'}); + LoggerProxy.info(`Fetched user data, userId: ${userConfigData.ciUserId}`, { + module: CONFIG_FILE_NAME, + method: METHODS.GET_AGENT_CONFIG, + }); const agentProfilePromise = this.getDesktopProfileById(orgId, userConfigData.agentProfileId); const siteInfoPromise = this.getSiteInfo(orgId, userConfigData.siteId); @@ -100,7 +113,7 @@ export default class AgentConfigService { LoggerProxy.info('Fetched all required data', { module: CONFIG_FILE_NAME, - method: 'getAgentConfig', + method: METHODS.GET_AGENT_CONFIG, }); const response = parseAgentConfigs({ @@ -116,21 +129,20 @@ export default class AgentConfigService { multimediaProfileId, }); - // replace CONFIG_FILE_NAME with CONFIG_FILE_NAME LoggerProxy.info('Parsing completed for agent-config', { module: CONFIG_FILE_NAME, - method: 'getAgentConfig', + method: METHODS.GET_AGENT_CONFIG, }); LoggerProxy.info('Fetched configuration data successfully', { module: CONFIG_FILE_NAME, - method: 'getAgentConfig', + method: METHODS.GET_AGENT_CONFIG, }); return response; } catch (error) { LoggerProxy.error(`getAgentConfig call failed with ${error}`, { module: CONFIG_FILE_NAME, - method: 'getAgentConfig', + method: METHODS.GET_AGENT_CONFIG, }); throw error; } @@ -138,11 +150,19 @@ export default class AgentConfigService { /** * Fetches the agent configuration data for the given orgId and agentId. - * @param {string} orgId - * @param {string} agentId - * @returns {Promise} + * @ignore + * @param {string} orgId - organization ID for which the agent configuration is to be fetched. + * @param {string} agentId - agent ID for which the configuration is to be fetched. + * @returns {Promise} - A promise that resolves to the agent configuration response. + * @throws {Error} - Throws an error if the API call fails or if the response status is not 200. + * @private */ public async getUserUsingCI(orgId: string, agentId: string): Promise { + LoggerProxy.info('Fetching user data using CI', { + module: CONFIG_FILE_NAME, + method: METHODS.GET_USER_USING_CI, + }); + try { const resource = endPointMap.userByCI(orgId, agentId); const response = await this.webexReq.request({ @@ -157,14 +177,14 @@ export default class AgentConfigService { LoggerProxy.log('getUserUsingCI api success.', { module: CONFIG_FILE_NAME, - method: 'getUserUsingCI', + method: METHODS.GET_USER_USING_CI, }); return Promise.resolve(response.body); } catch (error) { LoggerProxy.error(`getUserUsingCI API call failed with ${error}`, { module: CONFIG_FILE_NAME, - method: 'getUserUsingCI', + method: METHODS.GET_USER_USING_CI, }); throw error; } @@ -172,14 +192,22 @@ export default class AgentConfigService { /** * Fetches the desktop profile data for the given orgId and desktopProfileId. - * @param {string} orgId - * @param {string} desktopProfileId - * @returns {Promise} + * @ignore + * @param {string} orgId - organization ID for which the desktop profile is to be fetched. + * @param {string} desktopProfileId - desktop profile ID for which the data is to be fetched. + * @returns {Promise} - A promise that resolves to the desktop profile response. + * @throws {Error} - Throws an error if the API call fails or if the response status is not 200. + * @private */ public async getDesktopProfileById( orgId: string, desktopProfileId: string ): Promise { + LoggerProxy.info('Fetching desktop profile', { + module: CONFIG_FILE_NAME, + method: METHODS.GET_DESKTOP_PROFILE_BY_ID, + }); + try { const resource = endPointMap.desktopProfile(orgId, desktopProfileId); const response = await this.webexReq.request({ @@ -194,14 +222,14 @@ export default class AgentConfigService { LoggerProxy.log('getDesktopProfileById api success.', { module: CONFIG_FILE_NAME, - method: 'getDesktopProfileById', + method: METHODS.GET_DESKTOP_PROFILE_BY_ID, }); return Promise.resolve(response.body); } catch (error) { LoggerProxy.error(`getDesktopProfileById API call failed with ${error}`, { module: CONFIG_FILE_NAME, - method: 'getDesktopProfileById', + method: METHODS.GET_DESKTOP_PROFILE_BY_ID, }); throw error; } @@ -209,14 +237,22 @@ export default class AgentConfigService { /** * Fetches the multimedia profile data for the given orgId and multimediaProfileId. - * @param {string} orgId - * @param {string} multimediaProfileId - * @returns {Promise} + * @ignore + * @param {string} orgId - organization ID for which the multimedia profile is to be fetched. + * @param {string} multimediaProfileId - multimedia profile ID for which the data is to be fetched. + * @throws {Error} - Throws an error if the API call fails or if the response status is not 200. + * @returns {Promise} - A promise that resolves to the multimedia profile response. + * @private */ public async getMultimediaProfileById( orgId: string, multimediaProfileId: string ): Promise { + LoggerProxy.info('Fetching multimedia profile', { + module: CONFIG_FILE_NAME, + method: METHODS.GET_MULTIMEDIA_PROFILE_BY_ID, + }); + try { const resource = endPointMap.multimediaProfile(orgId, multimediaProfileId); const response = await this.webexReq.request({ @@ -231,14 +267,14 @@ export default class AgentConfigService { LoggerProxy.log('getMultimediaProfileById API success.', { module: CONFIG_FILE_NAME, - method: 'getMultimediaProfileById', + method: METHODS.GET_MULTIMEDIA_PROFILE_BY_ID, }); return Promise.resolve(response.body); } catch (error) { LoggerProxy.error(`getMultimediaProfileById API call failed with ${error}`, { module: CONFIG_FILE_NAME, - method: 'getMultimediaProfileById', + method: METHODS.GET_MULTIMEDIA_PROFILE_BY_ID, }); throw error; } @@ -246,12 +282,15 @@ export default class AgentConfigService { /** * fetches the list of teams for the given orgId. - * @param {string} orgId - * @param {number} page - * @param {number} pageSize - * @param {string[]} filter - * @param {string[]} attributes - * @returns {Promise} + * @ignore + * @param {string} orgId - organization ID for which the teams are to be fetched. + * @param {number} page - the page number to fetch. + * @param {number} pageSize - the number of teams to fetch per page. + * @param {string[]} filter - optional filter criteria for the teams. + * @param {string[]} attributes - optional attributes to include in the response. + * @returns {Promise} - A promise that resolves to the list of teams response. + * @throws {Error} - Throws an error if the API call fails or if the response status is not 200. + * @private */ public async getListOfTeams( orgId: string, @@ -259,6 +298,11 @@ export default class AgentConfigService { pageSize: number, filter: string[] ): Promise { + LoggerProxy.info('Fetching list of teams', { + module: CONFIG_FILE_NAME, + method: METHODS.GET_LIST_OF_TEAMS, + }); + try { const resource = endPointMap.listTeams(orgId, page, pageSize, filter); const response = await this.webexReq.request({ @@ -273,14 +317,14 @@ export default class AgentConfigService { LoggerProxy.log('getListOfTeams api success.', { module: CONFIG_FILE_NAME, - method: 'getListOfTeams', + method: METHODS.GET_LIST_OF_TEAMS, }); return Promise.resolve(response.body); } catch (error) { LoggerProxy.error(`getListOfTeams API call failed with ${error}`, { module: CONFIG_FILE_NAME, - method: 'getListOfTeams', + method: METHODS.GET_LIST_OF_TEAMS, }); throw error; } @@ -288,11 +332,14 @@ export default class AgentConfigService { /** * Fetches all teams from all pages for the given orgId - * @param {string} orgId - * @param {number} pageSize - * @param {string[]} filter - * @param {string[]} attributes - * @returns {Promise} + * @ignore + * @param {string} orgId - organization ID for which the teams are to be fetched. + * @param {number} pageSize - the number of teams to fetch per page. + * @param {string[]} filter - optional filter criteria for the teams. + * @param {string[]} attributes - optional attributes to include in the response. + * @returns {Promise} - A promise that resolves to the list of teams. + * @throws {Error} - Throws an error if the API call fails or if the response status is not 200. + * @private */ public async getAllTeams(orgId: string, pageSize: number, filter: string[]): Promise { try { @@ -315,20 +362,23 @@ export default class AgentConfigService { } catch (error) { LoggerProxy.error(`getAllTeams API call failed with ${error}`, { module: CONFIG_FILE_NAME, - method: 'getAllTeams', + method: METHODS.GET_ALL_TEAMS, }); throw error; } } /** - * fetches the list of aux codes for the given orgId. - * @param {string} orgId - * @param {number} page - * @param {number} pageSize - * @param {string[]} filter - * @param {string[]} attributes - * @returns {Promise} + * fetches the list of aux codes for the given orgId. + * @ignore + * @param {string} orgId - organization ID for which the aux codes are to be fetched. + * @param {number} page - the page number to fetch. + * @param {number} pageSize - the number of aux codes to fetch per page. + * @param {string[]} filter - optional filter criteria for the aux codes. + * @param {string[]} attributes - optional attributes to include in the response. + * @returns {Promise} - A promise that resolves to the list of aux codes response. + * @throws {Error} - Throws an error if the API call fails or if the response status is not 200. + * @private */ public async getListOfAuxCodes( orgId: string, @@ -337,6 +387,11 @@ export default class AgentConfigService { filter: string[], attributes: string[] ): Promise { + LoggerProxy.info('Fetching list of aux codes', { + module: CONFIG_FILE_NAME, + method: METHODS.GET_LIST_OF_AUX_CODES, + }); + try { const resource = endPointMap.listAuxCodes(orgId, page, pageSize, filter, attributes); const response = await this.webexReq.request({ @@ -351,14 +406,14 @@ export default class AgentConfigService { LoggerProxy.log('getListOfAuxCodes api success.', { module: CONFIG_FILE_NAME, - method: 'getListOfAuxCodes', + method: METHODS.GET_LIST_OF_AUX_CODES, }); return Promise.resolve(response.body); } catch (error) { LoggerProxy.error(`getListOfAuxCodes API call failed with ${error}`, { module: CONFIG_FILE_NAME, - method: 'getListOfAuxCodes', + method: METHODS.GET_LIST_OF_AUX_CODES, }); throw error; } @@ -366,11 +421,14 @@ export default class AgentConfigService { /** * Fetches all aux codes from all pages for the given orgId - * @param {string} orgId - * @param {number} pageSize - * @param {string[]} filter - * @param {string[]} attributes - * @returns {Promise} + * @ignore + * @param {string} orgId - organization ID for which the aux codes are to be fetched. + * @param {number} pageSize - the number of aux codes to fetch per page. + * @param {string[]} filter - optional filter criteria for the aux codes. + * @param {string[]} attributes - optional attributes to include in the response. + * @returns {Promise} - A promise that resolves to the list of aux codes. + * @throws {Error} - Throws an error if the API call fails or if the response status is not 200. + * @private */ public async getAllAuxCodes( orgId: string, @@ -401,7 +459,7 @@ export default class AgentConfigService { } catch (error) { LoggerProxy.error(`getAllAuxCodes API call failed with ${error}`, { module: CONFIG_FILE_NAME, - method: 'getAllAuxCodes', + method: METHODS.GET_ALL_AUX_CODES, }); throw error; } @@ -409,11 +467,18 @@ export default class AgentConfigService { /** * Fetches the site data for the given orgId and siteId. - * @param {string} orgId - * @param {string} siteId - * @returns {Promise} + * @ignore + * @param {string} orgId - organization ID for which the site info is to be fetched. + * @param {string} siteId - site ID for which the data is to be fetched. + * @returns {Promise} - A promise that resolves to the site info response. + * @throws {Error} - Throws an error if the API call fails or if the response status is not 200. + * @private */ public async getSiteInfo(orgId: string, siteId: string): Promise { + LoggerProxy.info('Fetching site information', { + module: CONFIG_FILE_NAME, + method: METHODS.GET_SITE_INFO, + }); try { const resource = endPointMap.siteInfo(orgId, siteId); const response = await this.webexReq.request({ @@ -428,14 +493,14 @@ export default class AgentConfigService { LoggerProxy.log('getSiteInfo api success.', { module: CONFIG_FILE_NAME, - method: 'getSiteInfo', + method: METHODS.GET_SITE_INFO, }); return Promise.resolve(response.body); } catch (error) { LoggerProxy.error(`getSiteInfo API call failed with ${error}`, { module: CONFIG_FILE_NAME, - method: 'getSiteInfo', + method: METHODS.GET_SITE_INFO, }); throw error; } @@ -443,8 +508,11 @@ export default class AgentConfigService { /** * Fetches the organization info for the given orgId. - * @param {string} orgId - * @returns {Promise} + * @ignore + * @param {string} orgId - organization ID for which the organization info is to be fetched. + * @returns {Promise} - A promise that resolves to the organization info response. + * @throws {Error} - Throws an error if the API call fails or if the response status is not 200. + * @private */ public async getOrgInfo(orgId: string): Promise { try { @@ -459,13 +527,16 @@ export default class AgentConfigService { throw new Error(`API call failed with ${response.statusCode}`); } - LoggerProxy.log('getOrgInfo api success.', {module: CONFIG_FILE_NAME, method: 'getOrgInfo'}); + LoggerProxy.log('getOrgInfo api success.', { + module: CONFIG_FILE_NAME, + method: METHODS.GET_ORG_INFO, + }); return Promise.resolve(response.body); } catch (error) { LoggerProxy.error(`getOrgInfo API call failed with ${error}`, { module: CONFIG_FILE_NAME, - method: 'getOrgInfo', + method: METHODS.GET_ORG_INFO, }); throw error; } @@ -473,8 +544,11 @@ export default class AgentConfigService { /** * Fetches the organization settings for the given orgId. - * @param {string} orgId - * @returns {Promise} + * @ignore + * @param {string} orgId - organization ID for which the organization settings are to be fetched. + * @returns {Promise} - A promise that resolves to the organization settings response. + * @throws {Error} - Throws an error if the API call fails or if the response status is not 200. + * @private */ public async getOrganizationSetting(orgId: string): Promise { try { @@ -491,14 +565,14 @@ export default class AgentConfigService { LoggerProxy.log('getOrganizationSetting api success.', { module: CONFIG_FILE_NAME, - method: 'getOrganizationSetting', + method: METHODS.GET_ORGANIZATION_SETTING, }); return Promise.resolve(response.body.data[0]); } catch (error) { LoggerProxy.error(`getOrganizationSetting API call failed with ${error}`, { module: CONFIG_FILE_NAME, - method: 'getOrganizationSetting', + method: METHODS.GET_ORGANIZATION_SETTING, }); throw error; } @@ -506,8 +580,11 @@ export default class AgentConfigService { /** * Fetches the tenant data for the given orgId. - * @param {string} orgId - * @returns {Promise} + * @ignore + * @param {string} orgId - organization ID for which the tenant data is to be fetched. + * @returns {Promise} - A promise that resolves to the tenant data response. + * @throws {Error} - Throws an error if the API call fails or if the response status is not 200. + * @private */ public async getTenantData(orgId: string): Promise { try { @@ -524,14 +601,14 @@ export default class AgentConfigService { LoggerProxy.log('getTenantData api success.', { module: CONFIG_FILE_NAME, - method: 'getTenantData', + method: METHODS.GET_TENANT_DATA, }); return Promise.resolve(response.body.data[0]); } catch (error) { LoggerProxy.error(`getTenantData API call failed with ${error}`, { module: CONFIG_FILE_NAME, - method: 'getTenantData', + method: METHODS.GET_TENANT_DATA, }); throw error; } @@ -539,8 +616,11 @@ export default class AgentConfigService { /** * Fetches the URL mapping data for the given orgId. - * @param {string} orgId - * @returns {Promise} + * @ignore + * @param {string} orgId - organization ID for which the URL mapping is to be fetched. + * @returns {Promise} - A promise that resolves to the URL mapping response. + * @throws {Error} - Throws an error if the API call fails or if the response status is not 200. + * @private */ public async getURLMapping(orgId: string): Promise { try { @@ -557,14 +637,14 @@ export default class AgentConfigService { LoggerProxy.log('getURLMapping api success.', { module: CONFIG_FILE_NAME, - method: 'getURLMapping', + method: METHODS.GET_URL_MAPPING, }); return Promise.resolve(response.body.data); } catch (error) { LoggerProxy.error(`getURLMapping API call failed with ${error}`, { module: CONFIG_FILE_NAME, - method: 'getURLMapping', + method: METHODS.GET_URL_MAPPING, }); throw error; } @@ -572,8 +652,11 @@ export default class AgentConfigService { /** * Fetches the dial plan data for the given orgId. - * @param {string} orgId - * @returns {Promise} + * @ignore + * @param {string} orgId - organization ID for which the dial plan data is to be fetched. + * @returns {Promise} - A promise that resolves to the dial plan data response. + * @throws {Error} - Throws an error if the API call fails or if the response status is not 200. + * @private */ public async getDialPlanData(orgId: string): Promise { try { @@ -590,14 +673,14 @@ export default class AgentConfigService { LoggerProxy.log('getDialPlanData api success.', { module: CONFIG_FILE_NAME, - method: 'getDialPlanData', + method: METHODS.GET_DIAL_PLAN_DATA, }); return Promise.resolve(response.body); } catch (error) { LoggerProxy.error(`getDialPlanData API call failed with ${error}`, { module: CONFIG_FILE_NAME, - method: 'getDialPlanData', + method: METHODS.GET_DIAL_PLAN_DATA, }); throw error; } @@ -605,12 +688,15 @@ export default class AgentConfigService { /** * Fetches the list of queues for the given orgId. - * @param {string} orgId - * @param {number} page - * @param {number} pageSize + * @ignore + * @param {string} orgId - organization ID for which the queues are to be fetched. + * @param {number} page - the page number to fetch. + * @param {number} pageSize - the number of queues to fetch per page. * @param {string} search - optional search string * @param {string} filter - optional filter string - * @returns Promise + * @returns Promise - A promise that resolves to the list of contact service queues. + * @throws {Error} - Throws an error if the API call fails or if the response status is not 200. + * @private */ public async getQueues( orgId: string, @@ -619,6 +705,11 @@ export default class AgentConfigService { search?: string, filter?: string ): Promise { + LoggerProxy.info('Fetching queue list', { + module: CONFIG_FILE_NAME, + method: METHODS.GET_QUEUES, + }); + try { let queryParams = `page=${page}&pageSize=${pageSize}&desktopProfileFilter=true`; if (search) queryParams += `&search=${search}`; @@ -635,13 +726,16 @@ export default class AgentConfigService { throw new Error(`API call failed with ${response.statusCode}`); } - LoggerProxy.log('getQueues API success.', {module: CONFIG_FILE_NAME, method: 'getQueues'}); + LoggerProxy.log('getQueues API success.', { + module: CONFIG_FILE_NAME, + method: METHODS.GET_QUEUES, + }); return response.body?.data; } catch (error) { LoggerProxy.error(`getQueues API call failed with ${error}`, { module: CONFIG_FILE_NAME, - method: 'getQueues', + method: METHODS.GET_QUEUES, }); throw error; } diff --git a/packages/@webex/plugin-cc/src/services/config/types.ts b/packages/@webex/plugin-cc/src/services/config/types.ts index bec7f39b44a..50cff8cda94 100644 --- a/packages/@webex/plugin-cc/src/services/config/types.ts +++ b/packages/@webex/plugin-cc/src/services/config/types.ts @@ -1,89 +1,180 @@ import * as Agent from '../agent/types'; +/** + * Generic type for converting a const enum object into a union type of its values + * @internal + */ type Enum> = T[keyof T]; -// Define the CC_TASK_EVENTS object -// These events are emitted on the task object +/** + * Events emitted on task objects + * @enum {string} + * @private + * @ignore + */ export const CC_TASK_EVENTS = { + /** Event emitted when assigning contact to agent fails */ AGENT_CONTACT_ASSIGN_FAILED: 'AgentContactAssignFailed', + /** Event emitted when agent does not respond to contact offer */ AGENT_CONTACT_OFFER_RONA: 'AgentOfferContactRona', + /** Event emitted when contact is put on hold */ AGENT_CONTACT_HELD: 'AgentContactHeld', + /** Event emitted when putting contact on hold fails */ AGENT_CONTACT_HOLD_FAILED: 'AgentContactHoldFailed', + /** Event emitted when contact is taken off hold */ AGENT_CONTACT_UNHELD: 'AgentContactUnheld', + /** Event emitted when taking contact off hold fails */ AGENT_CONTACT_UNHOLD_FAILED: 'AgentContactUnHoldFailed', + /** Event emitted when consultation is created */ AGENT_CONSULT_CREATED: 'AgentConsultCreated', + /** Event emitted when consultation is offered */ AGENT_OFFER_CONSULT: 'AgentOfferConsult', + /** Event emitted when agent is consulting */ AGENT_CONSULTING: 'AgentConsulting', + /** Event emitted when consultation fails */ AGENT_CONSULT_FAILED: 'AgentConsultFailed', + /** Event emitted when consulting to queue (CTQ) fails */ AGENT_CTQ_FAILED: 'AgentCtqFailed', + /** Event emitted when CTQ is cancelled */ AGENT_CTQ_CANCELLED: 'AgentCtqCancelled', + /** Event emitted when CTQ cancellation fails */ AGENT_CTQ_CANCEL_FAILED: 'AgentCtqCancelFailed', + /** Event emitted when consultation ends */ AGENT_CONSULT_ENDED: 'AgentConsultEnded', + /** Event emitted when ending consultation fails */ AGENT_CONSULT_END_FAILED: 'AgentConsultEndFailed', + /** Event emitted when consultation conference ends */ AGENT_CONSULT_CONFERENCE_ENDED: 'AgentConsultConferenceEnded', + /** Event emitted when contact is blind transferred */ AGENT_BLIND_TRANSFERRED: 'AgentBlindTransferred', + /** Event emitted when blind transfer fails */ AGENT_BLIND_TRANSFER_FAILED: 'AgentBlindTransferFailed', + /** Event emitted when contact is transferred to virtual team */ AGENT_VTEAM_TRANSFERRED: 'AgentVteamTransferred', + /** Event emitted when virtual team transfer fails */ AGENT_VTEAM_TRANSFER_FAILED: 'AgentVteamTransferFailed', + /** Event emitted when consultation transfer is in progress */ AGENT_CONSULT_TRANSFERRING: 'AgentConsultTransferring', + /** Event emitted when consultation transfer completes */ AGENT_CONSULT_TRANSFERRED: 'AgentConsultTransferred', + /** Event emitted when consultation transfer fails */ AGENT_CONSULT_TRANSFER_FAILED: 'AgentConsultTransferFailed', + /** Event emitted when contact recording is paused */ CONTACT_RECORDING_PAUSED: 'ContactRecordingPaused', + /** Event emitted when pausing contact recording fails */ CONTACT_RECORDING_PAUSE_FAILED: 'ContactRecordingPauseFailed', + /** Event emitted when contact recording is resumed */ CONTACT_RECORDING_RESUMED: 'ContactRecordingResumed', + /** Event emitted when resuming contact recording fails */ CONTACT_RECORDING_RESUME_FAILED: 'ContactRecordingResumeFailed', + /** Event emitted when contact ends */ CONTACT_ENDED: 'ContactEnded', + /** Event emitted when ending contact fails */ AGENT_CONTACT_END_FAILED: 'AgentContactEndFailed', + /** Event emitted when agent enters wrap-up state */ AGENT_WRAPUP: 'AgentWrapup', + /** Event emitted when agent completes wrap-up */ AGENT_WRAPPEDUP: 'AgentWrappedUp', + /** Event emitted when wrap-up fails */ AGENT_WRAPUP_FAILED: 'AgentWrapupFailed', + /** Event emitted when outbound call fails */ AGENT_OUTBOUND_FAILED: 'AgentOutboundFailed', + /** Event emitted for general agent contact events */ AGENT_CONTACT: 'AgentContact', + /** Event emitted when contact is offered to agent */ AGENT_OFFER_CONTACT: 'AgentOfferContact', + /** Event emitted when contact is assigned to agent */ AGENT_CONTACT_ASSIGNED: 'AgentContactAssigned', + /** Event emitted when contact is unassigned from agent */ AGENT_CONTACT_UNASSIGNED: 'AgentContactUnassigned', + /** Event emitted when inviting agent fails */ AGENT_INVITE_FAILED: 'AgentInviteFailed', } as const; -// Define the CC_AGENT_EVENTS object -// These events are emitted on the cc object +/** + * Events emitted on Contact Center agent operations + * @enum {string} + * @private + * @ignore + */ export const CC_AGENT_EVENTS = { + /** Welcome event when agent connects to websocket/backend */ WELCOME: 'Welcome', + /** Event emitted when agent re-login is successful */ AGENT_RELOGIN_SUCCESS: 'AgentReloginSuccess', + /** Event emitted when agent re-login fails */ AGENT_RELOGIN_FAILED: 'AgentReloginFailed', + /** Event emitted when agent DN registration completes */ AGENT_DN_REGISTERED: 'AgentDNRegistered', + /** Event emitted when agent initiates logout */ AGENT_LOGOUT: 'Logout', + /** Event emitted when agent logout is successful */ AGENT_LOGOUT_SUCCESS: 'AgentLogoutSuccess', + /** Event emitted when agent logout fails */ AGENT_LOGOUT_FAILED: 'AgentLogoutFailed', + /** Event emitted when agent initiates station login */ AGENT_STATION_LOGIN: 'StationLogin', + /** Event emitted when agent station login is successful */ AGENT_STATION_LOGIN_SUCCESS: 'AgentStationLoginSuccess', + /** Event emitted when agent station login fails */ AGENT_STATION_LOGIN_FAILED: 'AgentStationLoginFailed', + /** Event emitted when agent's state changes */ AGENT_STATE_CHANGE: 'AgentStateChange', + /** Event emitted when multiple logins detected for same agent */ AGENT_MULTI_LOGIN: 'AGENT_MULTI_LOGIN', + /** Event emitted when agent state change is successful */ AGENT_STATE_CHANGE_SUCCESS: 'AgentStateChangeSuccess', + /** Event emitted when agent state change fails */ AGENT_STATE_CHANGE_FAILED: 'AgentStateChangeFailed', + /** Event emitted when requesting buddy agents list */ AGENT_BUDDY_AGENTS: 'BuddyAgents', + /** Event emitted when buddy agents list is successfully retrieved */ AGENT_BUDDY_AGENTS_SUCCESS: 'BuddyAgents', + /** Event emitted when retrieving buddy agents list fails */ AGENT_BUDDY_AGENTS_RETRIEVE_FAILED: 'BuddyAgentsRetrieveFailed', + /** Event emitted when contact is reserved for agent */ AGENT_CONTACT_RESERVED: 'AgentContactReserved', } as const; -// Define the CC_EVENTS object +/** + * Combined Contact Center events including both agent and task events + * @enum {string} + * @public + */ export const CC_EVENTS = { ...CC_AGENT_EVENTS, ...CC_TASK_EVENTS, } as const; +/** + * Event data received when agent connects to the system + * @public + */ export type WelcomeEvent = { + /** ID of the agent that connected */ agentId: string; }; +/** + * Response type for welcome events which can be either success or error + * @public + */ export type WelcomeResponse = WelcomeEvent | Error; -// Derive the type using the utility type + +/** + * Type representing the union of all possible Contact Center events + * @public + */ export type CC_EVENTS = Enum; +/** + * WebSocket event structure for Contact Center events + * @public + */ export type WebSocketEvent = { + /** Type of the event */ type: CC_EVENTS; + /** Event payload data */ data: | WelcomeEvent | Agent.StationLoginSuccess @@ -301,30 +392,59 @@ export type DesktopProfileResponse = { stateSynchronizationWebex: boolean; }; +/** + * Response containing multimedia profile configuration for an agent + * Defines capabilities across different communication channels + * @private + */ export type MultimediaProfileResponse = { + /** Organization identifier */ organizationId: string; + /** Profile identifier */ id: string; + /** Version number of the profile */ version: number; + /** Profile name */ name: string; + /** Profile description */ description: string; + /** Maximum number of concurrent chat interactions */ chat: number; + /** Maximum number of concurrent email interactions */ email: number; + /** Maximum number of concurrent voice interactions */ telephony: number; + /** Maximum number of concurrent social media interactions */ social: number; + /** Whether the profile is active */ active: boolean; + /** Whether channel blending is enabled */ blendingModeEnabled: boolean; + /** Type of blending mode configuration */ blendingMode: string; + /** Whether this is the system default profile */ systemDefault: boolean; + /** Timestamp when profile was created */ createdTime: number; + /** Timestamp when profile was last updated */ lastUpdatedTime: number; }; +/** + * Response from subscription requests containing WebSocket connection details + * @public + */ export type SubscribeResponse = { + /** HTTP status code of the response */ statusCode: number; + /** Response body containing connection details */ body: { + /** WebSocket URL for real-time updates */ webSocketUrl?: string; + /** Unique subscription identifier */ subscriptionId?: string; }; + /** Optional status or error message */ message: string | null; }; @@ -375,65 +495,134 @@ export type ListAuxCodesResponse = { }; }; +/** + * Configuration for a team in the contact center system + * @private + */ export type TeamList = { + /** Unique identifier for the team */ id: string; + /** Team name */ name: string; + /** Type of team (e.g., 'AGENT_BASED') */ teamType: string; + /** Current status of the team */ teamStatus: string; + /** Whether the team is active */ active: boolean; + /** Site identifier where team is located */ siteId: string; + /** Name of the site */ siteName: string; + /** Optional multimedia profile ID for team */ multiMediaProfileId?: string; + /** List of user IDs belonging to team */ userIds: string[]; + /** Whether queue rankings are enabled for team */ rankQueuesForTeam: boolean; + /** Ordered list of queue rankings */ queueRankings: string[]; + /** Optional database identifier */ dbId?: string; + /** Optional desktop layout identifier */ desktopLayoutId?: string; }; +/** + * Response type for listing teams with pagination metadata + * @private + */ export type ListTeamsResponse = { + /** Array of team configurations */ data: TeamList[]; + /** Pagination metadata */ meta: { + /** Current page number */ page: number; + /** Number of items per page */ pageSize: number; + /** Total number of pages */ totalPages: number; + /** Total number of records */ totalRecords: number; }; }; +/** + * Basic organization information in the contact center system + * @private + * @ignore + */ export type OrgInfo = { + /** Tenant identifier */ tenantId: string; + /** Organization timezone */ timezone: string; }; +/** + * Organization-wide feature settings and configurations + * @private + */ export type OrgSettings = { + /** Whether WebRTC functionality is enabled */ webRtcEnabled: boolean; + /** Whether sensitive data masking is enabled */ maskSensitiveData: boolean; + /** Whether campaign manager features are enabled */ campaignManagerEnabled: boolean; }; +/** + * Contact center site configuration information + * @private + */ export type SiteInfo = { + /** Unique site identifier */ id: string; + /** Site name */ name: string; + /** Whether site is active */ active: boolean; + /** Multimedia profile ID for site */ multimediaProfileId: string; + /** Whether this is the system default site */ systemDefault: boolean; }; +/** + * Tenant-level configuration data and settings + * @private + */ export type TenantData = { + /** Desktop inactivity timeout in minutes */ timeoutDesktopInactivityMins: number; + /** Whether default DN is enforced */ forceDefaultDn: boolean; + /** Regex pattern for default DN validation */ dnDefaultRegex: string; + /** Regex pattern for other DN validation */ dnOtherRegex: string; + /** Whether privacy shield feature is visible */ privacyShieldVisible: boolean; + /** Whether outbound dialing is enabled */ outdialEnabled: boolean; + /** Whether ending calls is enabled */ endCallEnabled: boolean; + /** Whether ending consultations is enabled */ endConsultEnabled: boolean; + /** Whether call variables are suppressed */ callVariablesSuppressed: boolean; + /** Whether desktop inactivity timeout is enabled */ timeoutDesktopInactivityEnabled: boolean; + /** Lost connection recovery timeout in seconds */ lostConnectionRecoveryTimeout: number; }; +/** + * URL mapping configuration for external integrations + * @public + */ export type URLMapping = { id: string; name: string; @@ -443,21 +632,54 @@ export type URLMapping = { lastUpdatedTime: number; }; +/** + * Constant representing idle code + * @public + * @ignore + */ export const IDLE_CODE = 'IDLE_CODE'; + +/** + * Constant representing wrap up code + * @public + * @ignore + */ export const WRAP_UP_CODE = 'WRAP_UP_CODE'; + +/** + * Type representing the possible auxiliary code types + * @public + */ export type AuxCodeType = typeof IDLE_CODE | typeof WRAP_UP_CODE; +/** + * Sort order configuration for queries + * @internal + */ type SortOrder = { + /** Property to sort by */ property: string; + /** Sort order direction */ order: string; }; +/** + * Search query configuration + * @internal + */ type SearchQuery = { + /** Properties to search in */ properties: string; + /** Search value */ value: string; }; +/** + * Parameters for querying Contact Center resources + * @public + */ export type QueryParams = { + /** Page number for pagination */ pageNumber?: number; pageSize?: number; attributes?: Array; @@ -475,192 +697,421 @@ export type QueryParams = { desktopProfileFilter?: boolean; }; -export type Entity = {isSystem: boolean; name: string; id: string; isDefault: boolean}; +/** + * Basic entity information used throughout the system + * @public + */ +export type Entity = { + /** Whether this is a system entity */ + isSystem: boolean; + /** Entity name */ + name: string; + /** Unique entity identifier */ + id: string; + /** Whether this is the default entity */ + isDefault: boolean; +}; +/** + * Dial plan entity definition containing number manipulation rules + * @public + */ export type DialPlanEntity = { + /** Unique identifier for the dial plan */ id: string; + /** Regular expression pattern for matching numbers */ regularExpression: string; + /** Prefix to add to matched numbers */ prefix: string; + /** Characters to strip from matched numbers */ strippedChars: string; + /** Name of the dial plan */ name: string; }; +/** + * Complete dial plan configuration for number handling + * @public + */ export type DialPlan = { - type: string; // 'adhocDial' - dialPlanEntity: {regex: string; prefix: string; strippedChars: string; name: string}[]; + /** Type of dial plan (e.g., 'adhocDial') */ + type: string; + /** List of dial plan entities with transformation rules */ + dialPlanEntity: { + /** Regular expression pattern */ + regex: string; + /** Number prefix */ + prefix: string; + /** Characters to strip */ + strippedChars: string; + /** Entity name */ + name: string; + }[]; }; +/** + * Agent wrap-up codes configuration with pagination metadata + * @public + */ export type agentWrapUpCodes = { + /** Array of wrap-up code entities */ data: Entity[]; + /** Pagination and navigation metadata */ meta: { - links: {first: string; last: string; next: string; self: string}; + /** Navigation URLs for pagination */ + links: { + /** URL for first page */ + first: string; + /** URL for last page */ + last: string; + /** URL for next page */ + next: string; + /** URL for current page */ + self: string; + }; + /** Organization identifier */ orgid: string; + /** Current page number */ page: number; + /** Number of items per page */ pageSize: number; + /** Total number of pages */ totalPages: number; + /** Total number of records */ totalRecords: number; }; }; +/** + * Default wrap-up code configuration for an agent + * @public + */ export type agentDefaultWrapupCode = { + /** Unique identifier for the wrap-up code */ id: string; + /** Display name of the wrap-up code */ name: string; }; +/** + * Wrap-up reason configuration used to classify completed interactions + * @public + */ export type WrapUpReason = { + /** Whether this is a system-defined reason */ isSystem: boolean; + /** Display name of the reason */ name: string; + /** Unique identifier */ id: string; + /** Whether this is the default reason */ isDefault: boolean; }; +/** + * Wrap-up configuration data containing settings and available options + * @public + */ export type WrapupData = { + /** Wrap-up configuration properties */ wrapUpProps: { + /** Whether automatic wrap-up is enabled */ autoWrapup?: boolean; + /** Time in seconds before auto wrap-up triggers */ autoWrapupInterval?: number; + /** Whether last agent routing is enabled */ lastAgentRoute?: boolean; + /** List of available wrap-up reasons */ wrapUpReasonList: Array; + /** List of available wrap-up codes */ wrapUpCodesList?: Array; + /** Access control for idle codes ('ALL' or 'SPECIFIC') */ idleCodesAccess?: 'ALL' | 'SPECIFIC'; + /** Associated interaction identifier */ interactionId?: string; + /** Whether cancelling auto wrap-up is allowed */ allowCancelAutoWrapup?: boolean; }; }; +/** + * Available login options for voice channel access + * 'AGENT_DN' - Login using agent's DN + * 'EXTENSION' - Login using extension number + * 'BROWSER' - Login using browser-based WebRTC + * @public + */ export type LoginOption = 'AGENT_DN' | 'EXTENSION' | 'BROWSER'; +/** + * Team configuration information + * @public + */ export type Team = { + /** Unique team identifier */ teamId: string; + /** Team display name */ teamName: string; + /** Optional desktop layout configuration identifier */ desktopLayoutId?: string; }; +/** + * Basic queue configuration information + * @public + */ export type Queue = { + /** Queue identifier */ queueId: string; + /** Queue display name */ queueName: string; }; +/** + * URL mappings for external system integrations + * @public + */ export type URLMappings = { + /** Acqueon API endpoint URL */ acqueonApiUrl: string; + /** Acqueon console URL */ acqueonConsoleUrl: string; }; /** - * Represents the Agent Profile/configuration. + * Comprehensive agent profile configuration in the contact center system + * Contains all settings and capabilities for an agent * @public */ export type Profile = { + /** Microsoft Teams integration configuration */ microsoftConfig?: { + /** Whether to show user details in Teams */ showUserDetailsMS?: boolean; + /** Whether to sync agent state with Teams */ stateSynchronizationMS?: boolean; }; + /** Webex integration configuration */ webexConfig?: { + /** Whether to show user details in Webex */ showUserDetailsWebex?: boolean; + /** Whether to sync agent state with Webex */ stateSynchronizationWebex?: boolean; }; + /** List of teams the agent belongs to */ teams: Team[]; + /** Agent's default dial number */ defaultDn: string; + dn?: string; + /** Whether default DN is enforced at tenant level */ forceDefaultDn: boolean; + /** Whether default DN is enforced for this agent */ forceDefaultDnForAgent: boolean; + /** Regex pattern for US phone number validation */ regexUS: RegExp | string; + /** Regex pattern for international phone number validation */ regexOther: RegExp | string; + /** Unique identifier for the agent */ agentId: string; + /** Display name for the agent */ agentName: string; + /** Email address for the agent */ agentMailId: string; + /** Agent's profile configuration ID */ agentProfileID: string; + /** Dial plan configuration for number handling */ dialPlan: DialPlan; + /** Multimedia profile defining channel capabilities */ multimediaProfileId: string; + /** Skill profile defining agent competencies */ skillProfileId: string; + /** Site where agent is located */ siteId: string; + /** Enterprise-wide identifier */ enterpriseId: string; + /** Whether privacy shield feature is visible */ privacyShieldVisible: boolean; + /** Available idle codes */ idleCodes: Entity[]; + /** List of specific idle codes */ idleCodesList?: Array; + /** Access control for idle codes */ idleCodesAccess?: 'ALL' | 'SPECIFIC'; + /** Available wrap-up codes */ wrapupCodes: Entity[]; + /** Agent-specific wrap-up codes */ agentWrapUpCodes?: agentWrapUpCodes; + /** Default wrap-up code for agent */ agentDefaultWrapUpCode?: agentDefaultWrapupCode; + /** Default wrap-up code identifier */ defaultWrapupCode: string; + /** Wrap-up configuration data */ wrapUpData: WrapupData; + /** Organization identifier */ orgId?: string; + /** Whether outbound is enabled at tenant level */ isOutboundEnabledForTenant: boolean; + /** Whether outbound is enabled for this agent */ isOutboundEnabledForAgent: boolean; + /** Whether ad-hoc dialing is enabled */ isAdhocDialingEnabled: boolean; + /** Whether agent becomes available after outdial */ isAgentAvailableAfterOutdial: boolean; + /** Whether campaign management is enabled */ isCampaignManagementEnabled: boolean; + /** Outbound entry point */ outDialEp: string; + /** Whether ending calls is enabled */ isEndCallEnabled: boolean; + /** Whether ending consultations is enabled */ isEndConsultEnabled: boolean; + /** Optional lifecycle manager URL */ lcmUrl?: string; + /** Database identifier for agent */ agentDbId: string; + /** Optional analyzer identifier for agent */ agentAnalyzerId?: string; + /** Whether consult to queue is allowed */ allowConsultToQueue: boolean; + /** Additional campaign manager information */ campaignManagerAdditionalInfo?: string; + /** Whether personal statistics are enabled */ agentPersonalStatsEnabled: boolean; + /** Optional address book identifier */ addressBookId?: string; + /** Optional outbound ANI identifier */ outdialANIId?: string; + /** Optional analyzer user identifier */ analyserUserId?: string; + /** Whether call monitoring is enabled */ isCallMonitoringEnabled?: boolean; + /** Whether mid-call monitoring is enabled */ isMidCallMonitoringEnabled?: boolean; + /** Whether barge-in functionality is enabled */ isBargeInEnabled?: boolean; + /** Whether managed teams feature is enabled */ isManagedTeamsEnabled?: boolean; + /** Whether managed queues feature is enabled */ isManagedQueuesEnabled?: boolean; + /** Whether sending messages is enabled */ isSendMessageEnabled?: boolean; + /** Whether agent state changes are enabled */ isAgentStateChangeEnabled?: boolean; + /** Whether signing out agents is enabled */ isSignOutAgentsEnabled?: boolean; + /** Integration URL mappings */ urlMappings?: URLMappings; + /** Whether desktop inactivity timeout is enabled */ isTimeoutDesktopInactivityEnabled: boolean; + /** Desktop inactivity timeout in minutes */ timeoutDesktopInactivityMins?: number; + /** Whether analyzer features are enabled */ isAnalyzerEnabled?: boolean; + /** Tenant timezone */ tenantTimezone?: string; + /** Available voice login options */ loginVoiceOptions?: LoginOption[]; + /** Current login device type */ deviceType?: LoginOption; + /** Current team identifier */ currentTeamId?: string; + /** Whether WebRTC is enabled */ webRtcEnabled: boolean; + /** Organization-wide idle codes */ organizationIdleCodes?: Entity[]; + /** Whether recording management is enabled */ isRecordingManagementEnabled?: boolean; + /** Connection recovery timeout in milliseconds */ lostConnectionRecoveryTimeout: number; + /** Whether sensitive data masking is enabled */ maskSensitiveData?: boolean; + /** Whether agent is currently logged in */ isAgentLoggedIn?: boolean; + /** Last auxiliary code ID used for state change */ lastStateAuxCodeId?: string; + /** Timestamp of last state change */ lastStateChangeTimestamp?: number; + /** Timestamp of last idle code change */ lastIdleCodeChangeTimestamp?: number; }; +/** + * Contact distribution group configuration for routing logic + * @public + */ export type CallDistributionGroup = { - agentGroups: {teamId: string}[]; + /** List of agent groups in this distribution group */ + agentGroups: { + /** Team identifier */ + teamId: string; + }[]; + /** Distribution order priority */ order: number; + /** Distribution time duration in seconds */ duration: number; }; +/** + * Comprehensive configuration for a contact service queue + * @public + */ export type ContactServiceQueue = { + /** Unique identifier for the queue */ id: string; + /** Queue name */ name: string; + /** Queue description */ description: string; + /** Type of queue */ queueType: string; + /** Whether to check agent availability before routing */ checkAgentAvailability: boolean; + /** Type of channel this queue handles */ channelType: string; + /** Service level threshold in seconds */ serviceLevelThreshold: number; + /** Maximum number of active contacts allowed */ maxActiveContacts: number; + /** Maximum time contacts can wait in queue (seconds) */ maxTimeInQueue: number; + /** Default music on hold media file ID */ defaultMusicInQueueMediaFileId: string; + /** Queue timezone */ timezone: string; + /** Whether queue is active */ active: boolean; + /** Whether outbound campaign routing is enabled */ outdialCampaignEnabled: boolean; + /** Whether monitoring is permitted */ monitoringPermitted: boolean; + /** Whether parking is permitted */ parkingPermitted: boolean; + /** Whether recording is permitted */ recordingPermitted: boolean; + /** Whether recording all calls is permitted */ recordingAllCallsPermitted: boolean; + /** Whether pausing recordings is permitted */ pauseRecordingPermitted: boolean; + /** Maximum recording pause duration in seconds */ recordingPauseDuration: number; + /** Control flow script URL */ controlFlowScriptUrl: string; + /** IVR requeue URL */ ivrRequeueUrl: string; + /** Type of routing strategy */ routingType: string; + /** Queue-specific routing type */ queueRoutingType: string; + /** Queue skill requirements for routing */ queueSkillRequirements: object[]; + /** Associated agents */ agents: object[]; + /** Call distribution group configurations */ callDistributionGroups: CallDistributionGroup[]; + /** Associated resource links */ links: Array; + /** Timestamp when queue was created */ createdTime: string; + /** Timestamp when queue was last updated */ lastUpdatedTime: string; }; diff --git a/packages/@webex/plugin-cc/src/services/constants.ts b/packages/@webex/plugin-cc/src/services/constants.ts index 24bb3538b5e..19f650ea726 100644 --- a/packages/@webex/plugin-cc/src/services/constants.ts +++ b/packages/@webex/plugin-cc/src/services/constants.ts @@ -1,16 +1,111 @@ +/** + * Post-authentication event name. + * @type {string} + * @public + * @example + * if (event === POST_AUTH) { ... } + * @ignore + */ export const POST_AUTH = 'postauth'; + +/** + * API gateway identifier for Webex Contact Center. + * @type {string} + * @public + * @ignore + */ export const WCC_API_GATEWAY = 'wcc-api-gateway'; + +/** + * Domain identifier for WCC Calling RTMS. + * @type {string} + * @public + * @ignore + */ export const WCC_CALLING_RTMS_DOMAIN = 'wcc-calling-rtms-domain'; + +/** + * Default RTMS domain for production use. + * @type {string} + * @public + * @ignore + */ export const DEFAULT_RTMS_DOMAIN = 'rtw.prod-us1.rtmsprod.net'; + +/** + * Timeout in milliseconds for WebSocket events. + * @type {number} + * @public + * @example + * setTimeout(() => { ... }, WEBSOCKET_EVENT_TIMEOUT); + * @ignore + */ export const WEBSOCKET_EVENT_TIMEOUT = 20000; +/** + * Agent role identifier. + * @type {string} + * @public + * @ignore + */ export const AGENT = 'agent'; // CC GATEWAY API URL PATHS +/** + * API path for notification subscription. + * @type {string} + * @public + * @ignore + */ export const SUBSCRIBE_API = 'v1/notification/subscribe'; + +/** + * API path for agent login. + * @type {string} + * @public + * @ignore + */ export const LOGIN_API = 'v1/agents/login'; + +/** + * Prefix for WebRTC-related API endpoints. + * @type {string} + * @public + * @ignore + */ export const WEB_RTC_PREFIX = 'webrtc-'; + +/** + * API path for agent session state changes. + * @type {string} + * @public + * @ignore + */ export const STATE_CHANGE_API = 'v1/agents/session/state'; +/** + * Message for deregistering WebCalling line and cleaning up resources. + * @type {string} + * @public + * @ignore + */ export const DEREGISTER_WEBCALLING_LINE_MSG = 'Deregistering WebCalling line and cleaning up resources'; + +// WebCallingService method names +export const METHODS = { + SET_LOGIN_OPTION: 'setLoginOption', + HANDLE_MEDIA_EVENT: 'handleMediaEvent', + HANDLE_DISCONNECT_EVENT: 'handleDisconnectEvent', + REGISTER_CALL_LISTENERS: 'registerCallListeners', + CLEAN_UP_CALL: 'cleanUpCall', + GET_RTMS_DOMAIN: 'getRTMSDomain', + REGISTER_WEB_CALLING_LINE: 'registerWebCallingLine', + DEREGISTER_WEB_CALLING_LINE: 'deregisterWebCallingLine', + ANSWER_CALL: 'answerCall', + MUTE_UNMUTE_CALL: 'muteUnmuteCall', + IS_CALL_MUTED: 'isCallMuted', + DECLINE_CALL: 'declineCall', + MAP_CALL_TO_TASK: 'mapCallToTask', + GET_TASK_ID_FOR_CALL: 'getTaskIdForCall', +}; diff --git a/packages/@webex/plugin-cc/src/services/core/Err.ts b/packages/@webex/plugin-cc/src/services/core/Err.ts index 5f42be5385b..961eb3f0855 100644 --- a/packages/@webex/plugin-cc/src/services/core/Err.ts +++ b/packages/@webex/plugin-cc/src/services/core/Err.ts @@ -1,6 +1,11 @@ import {WebexRequestPayload} from '../../types'; import {Failure} from './GlobalTypes'; +/** + * Err module provides a structured way to handle errors in the Contact Center plugin. + * @ignore + */ + export type ErrDetails = {status: number; type: string; trackingId: string}; export type AgentErrorIds = diff --git a/packages/@webex/plugin-cc/src/services/core/GlobalTypes.ts b/packages/@webex/plugin-cc/src/services/core/GlobalTypes.ts index 8995427fc32..4ba680197a6 100644 --- a/packages/@webex/plugin-cc/src/services/core/GlobalTypes.ts +++ b/packages/@webex/plugin-cc/src/services/core/GlobalTypes.ts @@ -1,14 +1,34 @@ +/** + * Generic message interface used throughout the plugin + * @template T - Type of the data payload (defaults to any) + * @private + * @ignore + */ export type Msg = { + /** Message/Event type identifier */ type: string; + /** Organization identifier */ orgId: string; + /** Unique tracking identifier for the message/Event */ trackingId: string; + /** Message/Event payload data */ data: T; }; +/** + * Represents a failure message with specific error details + * @private + * @ignore + */ export type Failure = Msg<{ + /** Agent identifier associated with the failure */ agentId: string; + /** Tracking identifier for the failure event */ trackingId: string; + /** Numeric code indicating the reason for failure */ reasonCode: number; + /** Organization identifier */ orgId: string; + /** Human-readable description of the failure reason */ reason: string; }>; diff --git a/packages/@webex/plugin-cc/src/services/core/Utils.ts b/packages/@webex/plugin-cc/src/services/core/Utils.ts index 1dbf01ddde5..02eae784b20 100644 --- a/packages/@webex/plugin-cc/src/services/core/Utils.ts +++ b/packages/@webex/plugin-cc/src/services/core/Utils.ts @@ -1,9 +1,17 @@ import * as Err from './Err'; -import {WebexRequestPayload} from '../../types'; +import {LoginOption, WebexRequestPayload} from '../../types'; import {Failure} from './GlobalTypes'; import LoggerProxy from '../../logger-proxy'; import WebexRequest from './WebexRequest'; +/** + * Extracts common error details from a Webex request payload. + * + * @param errObj - The Webex request payload object. + * @returns An object containing the tracking ID and message body. + * @private + * @ignore + */ const getCommonErrorDetails = (errObj: WebexRequestPayload) => { return { trackingId: errObj?.headers?.trackingid || errObj?.headers?.TrackingID, @@ -11,13 +19,72 @@ const getCommonErrorDetails = (errObj: WebexRequestPayload) => { }; }; +export const isValidDialNumber = (input: string): boolean => { + // This regex checks for a valid dial number format for only few countries such as US, Canada. + const regexForDn = /1[0-9]{3}[2-9][0-9]{6}([,]{1,10}[0-9]+){0,1}/; + + return regexForDn.test(input); +}; + +export const getStationLoginErrorData = (failure: Failure, loginOption: LoginOption) => { + let duplicateLocationMessage = 'This value is already in use'; + + if (loginOption === LoginOption.EXTENSION) { + duplicateLocationMessage = 'This extension is already in use'; + } + + if (loginOption === LoginOption.AGENT_DN) { + duplicateLocationMessage = + 'Dial number is in use. Try a different one. For help, reach out to your administrator or support team.'; + } + + const errorCodeMessageMap = { + DUPLICATE_LOCATION: { + message: duplicateLocationMessage, + fieldName: loginOption, + }, + INVALID_DIAL_NUMBER: { + message: + 'Enter a valid US dial number. For help, reach out to your administrator or support team.', + fieldName: loginOption, + }, + }; + + const defaultMessage = 'An error occurred while logging in to the station'; + const defaultFieldName = 'generic'; + + const reason = failure?.data?.reason || ''; + + return { + message: errorCodeMessageMap[reason]?.message || defaultMessage, + fieldName: errorCodeMessageMap[reason]?.fieldName || defaultFieldName, + }; +}; + +/** + * Extracts error details and logs the error. Also uploads logs for the error unless it is a silent relogin agent not found error. + * + * @param error - The error object, expected to have a `details` property of type Failure. + * @param methodName - The name of the method where the error occurred. + * @param moduleName - The name of the module where the error occurred. + * @returns An object containing the error instance and the reason string. + * @public + * @example + * const details = getErrorDetails(error, 'fetchData', 'DataModule'); + * if (details.error) { handleError(details.error); } + * @ignore + */ export const getErrorDetails = (error: any, methodName: string, moduleName: string) => { + let errData = {message: '', fieldName: ''}; + const failure = error.details as Failure; const reason = failure?.data?.reason ?? `Error while performing ${methodName}`; + if (!(reason === 'AGENT_NOT_FOUND' && methodName === 'silentReLogin')) { - LoggerProxy.error(`${methodName} failed with trackingId: ${failure?.trackingId}`, { + LoggerProxy.error(`${methodName} failed with reason: ${reason}`, { module: moduleName, method: methodName, + trackingId: failure?.trackingId, }); // we can add more conditions here if not needed for specific cases eg: silentReLogin WebexRequest.getInstance().uploadLogs({ @@ -25,12 +92,39 @@ export const getErrorDetails = (error: any, methodName: string, moduleName: stri }); } + if (methodName === 'stationLogin') { + errData = getStationLoginErrorData(failure, error.loginOption); + + LoggerProxy.error( + `${methodName} failed with reason: ${reason}, message: ${errData.message}, fieldName: ${errData.fieldName}`, + { + module: moduleName, + method: methodName, + trackingId: failure?.trackingId, + } + ); + } + + const err = new Error(reason ?? `Error while performing ${methodName}`); + // @ts-ignore - add custom property to the error object for backward compatibility + err.data = errData; + return { - error: new Error(reason ?? `Error while performing ${methodName}`), + error: err, reason, }; }; +/** + * Creates an error details object suitable for use with the Err.Details class. + * + * @param errObj - The Webex request payload object. + * @returns An instance of Err.Details with the generic failure message and extracted details. + * @public + * @example + * const errDetails = createErrDetailsObject(webexRequestPayload); + * @ignore + */ export const createErrDetailsObject = (errObj: WebexRequestPayload) => { const details = getCommonErrorDetails(errObj); diff --git a/packages/@webex/plugin-cc/src/services/core/WebexRequest.ts b/packages/@webex/plugin-cc/src/services/core/WebexRequest.ts index af05199044b..a66176960d3 100644 --- a/packages/@webex/plugin-cc/src/services/core/WebexRequest.ts +++ b/packages/@webex/plugin-cc/src/services/core/WebexRequest.ts @@ -1,5 +1,6 @@ import {WEBEX_REQUEST_FILE} from '../../constants'; import LoggerProxy from '../../logger-proxy'; +import {METHODS} from './constants'; import {METRIC_EVENT_NAMES} from '../../metrics/constants'; import MetricsManager from '../../metrics/MetricsManager'; import { @@ -59,7 +60,7 @@ class WebexRequest { ); LoggerProxy.info(`Logs uploaded successfully with feedbackId: ${feedbackId}`, { module: WEBEX_REQUEST_FILE, - method: 'uploadLogs', + method: METHODS.UPLOAD_LOGS, }); MetricsManager.getInstance().trackEvent( @@ -82,7 +83,7 @@ class WebexRequest { } catch (error) { LoggerProxy.error(`Error uploading logs: ${error}`, { module: WEBEX_REQUEST_FILE, - method: 'uploadLogs', + method: METHODS.UPLOAD_LOGS, }); MetricsManager.getInstance().trackEvent( diff --git a/packages/@webex/plugin-cc/src/services/core/aqm-reqs.ts b/packages/@webex/plugin-cc/src/services/core/aqm-reqs.ts index 3afe6061a67..d635a798eb7 100644 --- a/packages/@webex/plugin-cc/src/services/core/aqm-reqs.ts +++ b/packages/@webex/plugin-cc/src/services/core/aqm-reqs.ts @@ -1,10 +1,9 @@ import {Msg} from './GlobalTypes'; import * as Err from './Err'; import {HTTP_METHODS, WebexRequestPayload} from '../../types'; - import LoggerProxy from '../../logger-proxy'; import {CbRes, Conf, ConfEmpty, Pending, Req, Res, ResEmpty} from './types'; -import {TIMEOUT_REQ} from './constants'; +import {TIMEOUT_REQ, METHODS} from './constants'; import {AQM_REQS_FILE} from '../../constants'; import WebexRequest from './WebexRequest'; import {WebSocketManager} from './websocket/WebSocketManager'; @@ -98,12 +97,12 @@ export default class AqmReqs { if ('errId' in notifFail) { LoggerProxy.log(`Routing request failed: ${JSON.stringify(msg)}`, { module: AQM_REQS_FILE, - method: 'createPromise', + method: METHODS.CREATE_PROMISE, }); const eerr = new Err.Details(notifFail.errId, msg as any); LoggerProxy.log(`Routing request failed: ${eerr}`, { module: AQM_REQS_FILE, - method: 'createPromise', + method: METHODS.CREATE_PROMISE, }); reject(eerr); } else { @@ -157,7 +156,7 @@ export default class AqmReqs { } LoggerProxy.error(`Routing request timeout${keySuccess}${response!}${c.url}`, { module: AQM_REQS_FILE, - method: this.createPromise.name, + method: METHODS.CREATE_PROMISE, }); reject( new Err.Details('Service.aqm.reqs.Timeout', { @@ -218,7 +217,7 @@ export default class AqmReqs { if (event.type === 'Welcome') { LoggerProxy.info(`Welcome message from Notifs Websocket`, { module: AQM_REQS_FILE, - method: this.onMessage.name, + method: METHODS.ON_MESSAGE, }); return; @@ -227,7 +226,7 @@ export default class AqmReqs { if (event.keepalive === 'true') { LoggerProxy.info(`Keepalive from web socket`, { module: AQM_REQS_FILE, - method: this.onMessage.name, + method: METHODS.ON_MESSAGE, }); return; @@ -236,7 +235,7 @@ export default class AqmReqs { if (event.type === 'AgentReloginFailed') { LoggerProxy.info('Silently handling the agent relogin fail', { module: AQM_REQS_FILE, - method: this.onMessage.name, + method: METHODS.ON_MESSAGE, }); } @@ -266,7 +265,7 @@ export default class AqmReqs { if (!isHandled) { LoggerProxy.info(`event=missingEventHandler | [AqmReqs] missing routing message handler`, { module: AQM_REQS_FILE, - method: this.onMessage.name, + method: METHODS.ON_MESSAGE, }); } }; diff --git a/packages/@webex/plugin-cc/src/services/core/constants.ts b/packages/@webex/plugin-cc/src/services/core/constants.ts index 1ad54cec116..61505380812 100644 --- a/packages/@webex/plugin-cc/src/services/core/constants.ts +++ b/packages/@webex/plugin-cc/src/services/core/constants.ts @@ -1,11 +1,106 @@ +/** + * Interval in milliseconds for sending keepalive pings to the worker. + * @ignore + */ export const KEEPALIVE_WORKER_INTERVAL = 4000; + +/** + * Delay in milliseconds before resolving notification handlers. + * @ignore + */ export const NOTIFS_RESOLVE_DELAY = 1200; + +/** + * Timeout duration in milliseconds before forcefully closing a WebSocket connection. + * @ignore + */ export const CLOSE_SOCKET_TIMEOUT_DURATION = 16000; + +/** + * API endpoint used for connectivity or health checks. + * @ignore + */ export const PING_API_URL = '/health'; + +/** + * Timeout in milliseconds to wait for a welcome message after socket connection. + * @ignore + */ export const WELCOME_TIMEOUT = 30000; + +/** + * Event name used for real-time device (RTD) ping status. + * @ignore + */ export const RTD_PING_EVENT = 'rtd-online-status'; + +/** + * Timeout in milliseconds for individual HTTP requests. + * @ignore + */ export const TIMEOUT_REQ = 20000; + +/** + * Duration in milliseconds to wait before attempting lost connection recovery. + * @ignore + */ export const LOST_CONNECTION_RECOVERY_TIMEOUT = 50000; + +/** + * Duration in milliseconds after which a WebSocket disconnect is considered allowed or expected. + * @ignore + */ export const WS_DISCONNECT_ALLOWED = 8000; + +/** + * Interval in milliseconds to check for connectivity status. + * @ignore + */ export const CONNECTIVITY_CHECK_INTERVAL = 5000; + +/** + * Timeout in milliseconds for cleanly closing the WebSocket. + * @ignore + */ export const CLOSE_SOCKET_TIMEOUT = 16000; + +// Method names for core services +export const METHODS = { + // WebexRequest methods + REQUEST: 'request', + UPLOAD_LOGS: 'uploadLogs', + + // Utils methods + GET_ERROR_DETAILS: 'getErrorDetails', + GET_COMMON_ERROR_DETAILS: 'getCommonErrorDetails', + CREATE_ERR_DETAILS_OBJECT: 'createErrDetailsObject', + + // AqmReqs methods + REQ: 'req', + REQ_EMPTY: 'reqEmpty', + MAKE_API_REQUEST: 'makeAPIRequest', + CREATE_PROMISE: 'createPromise', + BIND_PRINT: 'bindPrint', + BIND_CHECK: 'bindCheck', + ON_MESSAGE: 'onMessage', + + // WebSocketManager methods + INIT_WEB_SOCKET: 'initWebSocket', + CLOSE: 'close', + HANDLE_CONNECTION_LOST: 'handleConnectionLost', + REGISTER: 'register', + CONNECT: 'connect', + WEB_SOCKET_ON_CLOSE_HANDLER: 'webSocketOnCloseHandler', + + // ConnectionService methods + SETUP_EVENT_LISTENERS: 'setupEventListeners', + DISPATCH_CONNECTION_EVENT: 'dispatchConnectionEvent', + CS_HANDLE_CONNECTION_LOST: 'handleConnectionLost', + CLEAR_TIMER_ON_RESTORE_FAILED: 'clearTimerOnRestoreFailed', + HANDLE_RESTORE_FAILED: 'handleRestoreFailed', + UPDATE_CONNECTION_DATA: 'updateConnectionData', + SET_CONNECTION_PROP: 'setConnectionProp', + ON_PING: 'onPing', + HANDLE_SOCKET_CLOSE: 'handleSocketClose', + ON_SOCKET_CLOSE: 'onSocketClose', +}; diff --git a/packages/@webex/plugin-cc/src/services/core/websocket/WebSocketManager.ts b/packages/@webex/plugin-cc/src/services/core/websocket/WebSocketManager.ts index fe9ebd03cc9..12e5d210b97 100644 --- a/packages/@webex/plugin-cc/src/services/core/websocket/WebSocketManager.ts +++ b/packages/@webex/plugin-cc/src/services/core/websocket/WebSocketManager.ts @@ -5,9 +5,15 @@ import {ConnectionLostDetails} from './types'; import {CC_EVENTS, SubscribeResponse, WelcomeResponse} from '../../config/types'; import LoggerProxy from '../../../logger-proxy'; import workerScript from './keepalive.worker'; -import {KEEPALIVE_WORKER_INTERVAL, CLOSE_SOCKET_TIMEOUT} from '../constants'; +import {KEEPALIVE_WORKER_INTERVAL, CLOSE_SOCKET_TIMEOUT, METHODS} from '../constants'; import {WEB_SOCKET_MANAGER_FILE} from '../../../constants'; +/** + * WebSocketManager handles the WebSocket connection for Contact Center operations. + * It manages the connection lifecycle, including registration, reconnection, and message handling. + * It also utilizes a Web Worker to manage keepalive messages and socket closure. + * @ignore + */ export class WebSocketManager extends EventEmitter { private websocket: WebSocket; shouldReconnect: boolean; @@ -47,7 +53,7 @@ export class WebSocketManager extends EventEmitter { this.connect().catch((error) => { LoggerProxy.error(`[WebSocketStatus] | Error in connecting Websocket ${error}`, { module: WEB_SOCKET_MANAGER_FILE, - method: this.initWebSocket.name, + method: METHODS.INIT_WEB_SOCKET, }); reject(error); }); @@ -61,7 +67,7 @@ export class WebSocketManager extends EventEmitter { this.keepaliveWorker.postMessage({type: 'terminate'}); LoggerProxy.log( `[WebSocketStatus] | event=webSocketClose | WebSocket connection closed manually REASON: ${reason}`, - {module: WEB_SOCKET_MANAGER_FILE, method: this.close.name} + {module: WEB_SOCKET_MANAGER_FILE, method: METHODS.CLOSE} ); } } @@ -82,7 +88,7 @@ export class WebSocketManager extends EventEmitter { } catch (e) { LoggerProxy.error( `Register API Failed, Request to RoutingNotifs websocket registration API failed ${e}`, - {module: WEB_SOCKET_MANAGER_FILE, method: this.register.name} + {module: WEB_SOCKET_MANAGER_FILE, method: METHODS.REGISTER} ); } } @@ -93,7 +99,7 @@ export class WebSocketManager extends EventEmitter { } LoggerProxy.log( `[WebSocketStatus] | event=webSocketConnecting | Connecting to WebSocket: ${this.url}`, - {module: WEB_SOCKET_MANAGER_FILE, method: this.connect.name} + {module: WEB_SOCKET_MANAGER_FILE, method: METHODS.CONNECT} ); this.websocket = new WebSocket(this.url); @@ -113,7 +119,7 @@ export class WebSocketManager extends EventEmitter { this.close(true, 'WebSocket did not auto close within 16 secs'); LoggerProxy.error( '[webSocketTimeout] | event=webSocketTimeout | WebSocket connection closed forcefully', - {module: WEB_SOCKET_MANAGER_FILE, method: this.connect.name} + {module: WEB_SOCKET_MANAGER_FILE, method: METHODS.CONNECT} ); } }; @@ -129,7 +135,7 @@ export class WebSocketManager extends EventEmitter { this.websocket.onerror = (event: any) => { LoggerProxy.error( `[WebSocketStatus] | event=socketConnectionFailed | WebSocket connection failed ${event}`, - {module: WEB_SOCKET_MANAGER_FILE, method: this.connect.name} + {module: WEB_SOCKET_MANAGER_FILE, method: METHODS.CONNECT} ); reject(); }; @@ -154,7 +160,7 @@ export class WebSocketManager extends EventEmitter { this.close(false, 'multiLogin'); LoggerProxy.error( '[WebSocketStatus] | event=agentMultiLogin | WebSocket connection closed by agent multiLogin', - {module: WEB_SOCKET_MANAGER_FILE, method: this.connect.name} + {module: WEB_SOCKET_MANAGER_FILE, method: METHODS.CONNECT} ); } }; @@ -174,7 +180,7 @@ export class WebSocketManager extends EventEmitter { const onlineStatus = navigator.onLine; LoggerProxy.info(`[WebSocketStatus] | desktop online status is ${onlineStatus}`, { module: WEB_SOCKET_MANAGER_FILE, - method: this.webSocketOnCloseHandler.name, + method: METHODS.WEB_SOCKET_ON_CLOSE_HANDLER, }); issueReason = !onlineStatus ? 'network issue' @@ -182,7 +188,7 @@ export class WebSocketManager extends EventEmitter { } LoggerProxy.error( `[WebSocketStatus] | event=webSocketClose | WebSocket connection closed REASON: ${issueReason}`, - {module: WEB_SOCKET_MANAGER_FILE, method: this.webSocketOnCloseHandler.name} + {module: WEB_SOCKET_MANAGER_FILE, method: METHODS.WEB_SOCKET_ON_CLOSE_HANDLER} ); this.forceCloseWebSocketOnTimeout = false; } diff --git a/packages/@webex/plugin-cc/src/services/core/websocket/connection-service.ts b/packages/@webex/plugin-cc/src/services/core/websocket/connection-service.ts index f9e42edba80..a65359f9de1 100644 --- a/packages/@webex/plugin-cc/src/services/core/websocket/connection-service.ts +++ b/packages/@webex/plugin-cc/src/services/core/websocket/connection-service.ts @@ -6,6 +6,7 @@ import { LOST_CONNECTION_RECOVERY_TIMEOUT, WS_DISCONNECT_ALLOWED, CONNECTIVITY_CHECK_INTERVAL, + METHODS, } from '../constants'; import {CONNECTION_SERVICE_FILE} from '../../../constants'; import {SubscribeRequest} from '../../../types'; @@ -53,9 +54,9 @@ export class ConnectionService extends EventEmitter { isKeepAlive: this.isKeepAlive, }; this.webSocketManager.handleConnectionLost(event); - LoggerProxy.log(`Dispatching connection event`, { + LoggerProxy.info(`Dispatching connection event`, { module: CONNECTION_SERVICE_FILE, - method: 'dispatchConnectionEvent', + method: METHODS.DISPATCH_CONNECTION_EVENT, }); this.emit('connectionLost', event); } @@ -119,7 +120,7 @@ export class ConnectionService extends EventEmitter { private handleSocketClose = async (): Promise => { LoggerProxy.info(`event=socketConnectionRetry | Trying to reconnect to websocket`, { module: CONNECTION_SERVICE_FILE, - method: 'handleSocketClose', + method: METHODS.HANDLE_SOCKET_CLOSE, }); const onlineStatus = navigator.onLine; if (onlineStatus) { diff --git a/packages/@webex/plugin-cc/src/services/core/websocket/keepalive.worker.js b/packages/@webex/plugin-cc/src/services/core/websocket/keepalive.worker.js index 1eaac795aeb..795993c7cdb 100644 --- a/packages/@webex/plugin-cc/src/services/core/websocket/keepalive.worker.js +++ b/packages/@webex/plugin-cc/src/services/core/websocket/keepalive.worker.js @@ -16,7 +16,7 @@ const resetOfflineHandler = function () { const checkOnlineStatus = function () { const onlineStatus = navigator.onLine; console.log( - \`[WebSocketStatus] event=checkOnlineStatus | timestamp=${new Date()}, UTC=${new Date().toUTCString()} | online status=\`, + \`[WebSocketStatus] event=checkOnlineStatus | online status=\`, onlineStatus ); return onlineStatus; diff --git a/packages/@webex/plugin-cc/src/services/core/websocket/types.ts b/packages/@webex/plugin-cc/src/services/core/websocket/types.ts index 653c19e05cf..5c1e3f35981 100644 --- a/packages/@webex/plugin-cc/src/services/core/websocket/types.ts +++ b/packages/@webex/plugin-cc/src/services/core/websocket/types.ts @@ -1,11 +1,27 @@ import {SubscribeRequest} from '../../../types'; import {WebSocketManager} from './WebSocketManager'; +/** + * Options for initializing a connection service. + * @typedef ConnectionServiceOptions + * @property {WebSocketManager} webSocketManager - The WebSocket manager instance. + * @property {SubscribeRequest} subscribeRequest - The subscribe request payload. + * @ignore + */ export type ConnectionServiceOptions = { webSocketManager: WebSocketManager; subscribeRequest: SubscribeRequest; }; +/** + * Details about the state of a lost connection and recovery attempts. + * @typedef ConnectionLostDetails + * @property {boolean} isConnectionLost - Indicates if the connection is currently lost. + * @property {boolean} isRestoreFailed - Indicates if restoring the connection has failed. + * @property {boolean} isSocketReconnected - Indicates if the socket has been reconnected. + * @property {boolean} isKeepAlive - Indicates if the keep-alive mechanism is active. + * @ignore + */ export type ConnectionLostDetails = { isConnectionLost: boolean; isRestoreFailed: boolean; @@ -13,6 +29,12 @@ export type ConnectionLostDetails = { isKeepAlive: boolean; }; +/** + * Properties for connection configuration. + * @typedef ConnectionProp + * @property {number} lostConnectionRecoveryTimeout - Timeout in milliseconds for lost connection recovery. + * @ignore + */ export type ConnectionProp = { lostConnectionRecoveryTimeout: number; }; diff --git a/packages/@webex/plugin-cc/src/services/index.ts b/packages/@webex/plugin-cc/src/services/index.ts index 00a7ae33fd2..9a8e188d9dc 100644 --- a/packages/@webex/plugin-cc/src/services/index.ts +++ b/packages/@webex/plugin-cc/src/services/index.ts @@ -7,15 +7,35 @@ import {ConnectionService} from './core/websocket/connection-service'; import {WebexSDK, SubscribeRequest} from '../types'; import aqmDialer from './task/dialer'; +/** + * Services class provides centralized access to all contact center plugin services + * using a singleton pattern to ensure a single instance throughout the application. + * @private + * @ignore + * @class + */ export default class Services { + /** Agent services for managing agent state and capabilities */ public readonly agent: ReturnType; + /** Configuration services for agent settings */ public readonly config: AgentConfigService; + /** Contact services for managing customer interactions */ public readonly contact: ReturnType; + /** Dialer services for outbound calling features */ public readonly dialer: ReturnType; + /** WebSocket manager for handling real-time communications */ public readonly webSocketManager: WebSocketManager; + /** Connection service for managing websocket connections */ public readonly connectionService: ConnectionService; + /** Singleton instance of the Services class */ private static instance: Services; + /** + * Creates a new Services instance + * @param options - Configuration options + * @param options.webex - WebexSDK instance + * @param options.connectionConfig - Subscription configuration for websocket connection + */ constructor(options: {webex: WebexSDK; connectionConfig: SubscribeRequest}) { const {webex, connectionConfig} = options; this.webSocketManager = new WebSocketManager({webex}); @@ -30,6 +50,14 @@ export default class Services { }); } + /** + * Gets singleton instance of Services class + * Creates a new instance if one doesn't exist + * @param options - Configuration options + * @param options.webex - WebexSDK instance + * @param options.connectionConfig - Subscription configuration for websocket connection + * @returns The singleton Services instance + */ public static getInstance(options: { webex: WebexSDK; connectionConfig: SubscribeRequest; diff --git a/packages/@webex/plugin-cc/src/services/task/TaskManager.ts b/packages/@webex/plugin-cc/src/services/task/TaskManager.ts index 60539f51246..9d7c1ec6713 100644 --- a/packages/@webex/plugin-cc/src/services/task/TaskManager.ts +++ b/packages/@webex/plugin-cc/src/services/task/TaskManager.ts @@ -5,6 +5,7 @@ import routingContact from './contact'; import WebCallingService from '../WebCallingService'; import {MEDIA_CHANNEL, TASK_EVENTS, TaskData, TaskId, ITask} from './types'; import {TASK_MANAGER_FILE} from '../../constants'; +import {METHODS} from './constants'; import {CC_EVENTS, CC_TASK_EVENTS} from '../config/types'; import {ConfigFlags, LoginOption} from '../../types'; import LoggerProxy from '../../logger-proxy'; @@ -13,9 +14,15 @@ import {METRIC_EVENT_NAMES} from '../../metrics/constants'; import TaskFactory from './TaskFactory'; import WebRTC from './voice/WebRTC'; +/** @internal */ export default class TaskManager extends EventEmitter { private call: ICall; private contact: ReturnType; + /** + * Collection of tasks indexed by TaskId + * @type {Record} + * @private + */ private taskCollection: Record; private webCallingService: WebCallingService; private webSocketManager: WebSocketManager; @@ -46,9 +53,10 @@ export default class TaskManager extends EventEmitter { if (currentTask) { this.webCallingService.mapCallToTask(call.getCallId(), currentTask.data.interactionId); - LoggerProxy.log('Call mapped to task', { + LoggerProxy.log(`Call mapped to task`, { module: TASK_MANAGER_FILE, - method: 'handleIncomingWebCall', + method: METHODS.HANDLE_INCOMING_WEB_CALL, + interactionId: currentTask.data.interactionId, }); this.emit(TASK_EVENTS.TASK_INCOMING, currentTask); } @@ -80,6 +88,11 @@ export default class TaskManager extends EventEmitter { if (Object.values(CC_TASK_EVENTS).includes(payload.data.type)) { task = this.taskCollection[payload.data.interactionId]; } + LoggerProxy.info(`Handling task event ${payload.data?.type}`, { + module: TASK_MANAGER_FILE, + method: METHODS.REGISTER_TASK_LISTENERS, + interactionId: payload.data?.interactionId, + }); switch (payload.data.type) { case CC_EVENTS.AGENT_CONTACT: this.taskCollection[payload.data.interactionId] = task; @@ -105,9 +118,10 @@ export default class TaskManager extends EventEmitter { break; case CC_EVENTS.AGENT_OFFER_CONTACT: this.updateTaskData(task, payload.data); - LoggerProxy.log('Agent offer contact', { + LoggerProxy.log(`Agent offer contact received for task`, { module: TASK_MANAGER_FILE, - method: 'registerTaskListeners', + method: METHODS.REGISTER_TASK_LISTENERS, + interactionId: payload.data?.interactionId, }); this.emit(TASK_EVENTS.TASK_OFFER_CONTACT, task); break; @@ -115,9 +129,10 @@ export default class TaskManager extends EventEmitter { if (task.data) { this.removeTaskFromCollection(task); } - LoggerProxy.log('Agent outbound failed', { + LoggerProxy.log(`Agent outbound failed for task`, { module: TASK_MANAGER_FILE, - method: 'registerTaskListeners', + method: METHODS.REGISTER_TASK_LISTENERS, + interactionId: payload.data?.interactionId, }); break; case CC_EVENTS.AGENT_CONTACT_ASSIGNED: @@ -163,7 +178,7 @@ export default class TaskManager extends EventEmitter { case CC_EVENTS.AGENT_CONTACT_UNHELD: // As soon as the main interaction is unheld, we need to emit TASK_RESUME this.updateTaskData(task, payload.data); - task.emit(TASK_EVENTS.TASK_RESUME, task); + task.emit(TASK_EVENTS.TASK_UNHOLD, task); break; case CC_EVENTS.AGENT_VTEAM_TRANSFERRED: this.updateTaskData(task, { @@ -265,11 +280,14 @@ export default class TaskManager extends EventEmitter { } private removeTaskFromCollection(task: ITask) { - delete this.taskCollection[task.data.interactionId]; - LoggerProxy.info(`Task removed: ${task.data.interactionId}`, { - module: TASK_MANAGER_FILE, - method: 'removeTaskFromCollection', - }); + if (task?.data?.interactionId) { + delete this.taskCollection[task.data.interactionId]; + LoggerProxy.info(`Task removed from collection`, { + module: TASK_MANAGER_FILE, + method: METHODS.REMOVE_TASK_FROM_COLLECTION, + interactionId: task.data.interactionId, + }); + } } private handleTaskCleanup(task: ITask) { diff --git a/packages/@webex/plugin-cc/src/services/task/constants.ts b/packages/@webex/plugin-cc/src/services/task/constants.ts index 96d1a933b20..aadafe9ed78 100644 --- a/packages/@webex/plugin-cc/src/services/task/constants.ts +++ b/packages/@webex/plugin-cc/src/services/task/constants.ts @@ -1,3 +1,9 @@ +/** + * Constants for Task Service + * @module @webex/plugin-cc/services/task/constants + * @ignore + */ + export const TASK_MESSAGE_TYPE = 'RoutingMessage'; export const TASK_API = '/v1/tasks/'; export const HOLD = '/hold'; @@ -13,3 +19,32 @@ export const WRAPUP = '/wrapup'; export const END = '/end'; export const TASK_MANAGER_FILE = 'taskManager'; export const TASK_FILE = 'task'; + +// METHOD NAMES +export const METHODS = { + // Task class methods + ACCEPT: 'accept', + TOGGLE_MUTE: 'toggleMute', + DECLINE: 'decline', + HOLD: 'hold', + RESUME: 'resume', + END: 'end', + WRAPUP: 'wrapup', + PAUSE_RECORDING: 'pauseRecording', + RESUME_RECORDING: 'resumeRecording', + CONSULT: 'consult', + END_CONSULT: 'endConsult', + TRANSFER: 'transfer', + CONSULT_TRANSFER: 'consultTransfer', + UPDATE_TASK_DATA: 'updateTaskData', + RECONCILE_DATA: 'reconcileData', + + // TaskManager class methods + HANDLE_INCOMING_WEB_CALL: 'handleIncomingWebCall', + REGISTER_TASK_LISTENERS: 'registerTaskListeners', + REMOVE_TASK_FROM_COLLECTION: 'removeTaskFromCollection', + HANDLE_TASK_CLEANUP: 'handleTaskCleanup', + GET_TASK: 'getTask', + GET_ALL_TASKS: 'getAllTasks', + GET_TASK_MANAGER: 'getTaskManager', +}; diff --git a/packages/@webex/plugin-cc/src/services/task/dialer.ts b/packages/@webex/plugin-cc/src/services/task/dialer.ts index 6680cc730fa..c1c45e43a1e 100644 --- a/packages/@webex/plugin-cc/src/services/task/dialer.ts +++ b/packages/@webex/plugin-cc/src/services/task/dialer.ts @@ -5,10 +5,28 @@ import {TASK_MESSAGE_TYPE, TASK_API} from './constants'; import * as Contact from './types'; import AqmReqs from '../core/aqm-reqs'; +/** + * Returns an object with AQM dialer functions used for outbound contact handling. + * + * @param {AqmReqs} aqm - An instance of AQM request handler. + * @returns {{ + * startOutdial: (params: {data: Contact.DialerPayload}) => Promise + * }} Object containing methods for outbound dialing. + * @ignore + */ export default function aqmDialer(aqm: AqmReqs) { return { - /* - * Make outbound request. + /** + * Initiates an outbound contact (outdial) request. + * + * @param {Object} p - Parameters object. + * @param {Contact.DialerPayload} p.data - Payload for the outbound call. + * @returns {Promise} A promise that resolves or rejects based on the outbound call response. + * + * Emits: + * - `CC_EVENTS.AGENT_OFFER_CONTACT` on success + * - `CC_EVENTS.AGENT_OUTBOUND_FAILED` on failure + * @ignore */ startOutdial: aqm.req((p: {data: Contact.DialerPayload}) => ({ url: `${TASK_API}`, diff --git a/packages/@webex/plugin-cc/src/services/task/index.ts b/packages/@webex/plugin-cc/src/services/task/index.ts index 084fc065ffc..97d18c78c8a 100644 --- a/packages/@webex/plugin-cc/src/services/task/index.ts +++ b/packages/@webex/plugin-cc/src/services/task/index.ts @@ -3,8 +3,10 @@ import {CALL_EVENT_KEYS, LocalMicrophoneStream} from '@webex/calling'; import {CallId} from '@webex/calling/dist/types/common/types'; import {getErrorDetails} from '../core/Utils'; import {LoginOption} from '../../types'; -import {CC_FILE} from '../../constants'; +import {TASK_FILE} from '../../constants'; +import {METHODS} from './constants'; import routingContact from './contact'; +import LoggerProxy from '../../logger-proxy'; import { IOldTask, TaskResponse, @@ -26,6 +28,16 @@ import MetricsManager from '../../metrics/MetricsManager'; import {METRIC_EVENT_NAMES} from '../../metrics/constants'; import {Failure} from '../core/GlobalTypes'; +/** + * Task class represents a contact center task/interaction that can be managed by an agent. + * This class provides all the necessary methods to manage tasks in a contact center enivornment, handling various call control operations and task lifecycle management. + * @implements {ITask} + * @example + * ```typescript + * const task = new Task(contact, webCallingService, taskData); + * ``` + */ + export default class Task extends EventEmitter implements IOldTask { private contact: ReturnType; private localAudioStream: LocalMicrophoneStream; @@ -34,6 +46,12 @@ export default class Task extends EventEmitter implements IOldTask { private metricsManager: MetricsManager; public webCallMap: Record; + /** + * Creates a new Task instance + * @param contact - The routing contact service instance + * @param webCallingService - The web calling service instance + * @param data - Initial task data + */ constructor( contact: ReturnType, webCallingService: WebCallingService, @@ -48,19 +66,30 @@ export default class Task extends EventEmitter implements IOldTask { this.registerWebCallListeners(); } + /** + * @ignore + * @private + */ private handleRemoteMedia = (track: MediaStreamTrack) => { this.emit(TASK_EVENTS.TASK_MEDIA, track); }; + /** + * @ignore + * @private + */ private registerWebCallListeners() { this.webCallingService.on(CALL_EVENT_KEYS.REMOTE_MEDIA, this.handleRemoteMedia); } + /** + * @ignore + */ public unregisterWebCallListeners() { this.webCallingService.off(CALL_EVENT_KEYS.REMOTE_MEDIA, this.handleRemoteMedia); } - public updateTaskData = (updatedData: TaskData, shouldOverwrite = false) => { + public updateTaskData = (updatedData: TaskData, shouldOverwrite = false): IOldTask => { this.data = shouldOverwrite ? updatedData : this.reconcileData(this.data, updatedData); return this; @@ -79,7 +108,7 @@ export default class Task extends EventEmitter implements IOldTask { } /** - * This is used for incoming task accept by agent. + * Agent accepts the incoming task. * * @returns Promise * @throws Error @@ -90,13 +119,34 @@ export default class Task extends EventEmitter implements IOldTask { */ public async accept(): Promise { try { + LoggerProxy.info(`Accepting task`, { + module: TASK_FILE, + method: METHODS.ACCEPT, + interactionId: this.data.interactionId, + }); this.metricsManager.timeEvent([ METRIC_EVENT_NAMES.TASK_ACCEPT_SUCCESS, METRIC_EVENT_NAMES.TASK_ACCEPT_FAILED, ]); if (this.data.interaction.mediaType !== MEDIA_CHANNEL.TELEPHONY) { - return this.contact.accept({interactionId: this.data.interactionId}); + const response = await this.contact.accept({interactionId: this.data.interactionId}); + LoggerProxy.log(`Task accepted successfully`, { + module: TASK_FILE, + method: METHODS.ACCEPT, + trackingId: response.trackingId, + interactionId: this.data.interactionId, + }); + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.TASK_ACCEPT_SUCCESS, + { + taskId: this.data.interactionId, + ...MetricsManager.getCommonTrackingFieldForAQMResponse(this.data), + }, + ['operational', 'behavioral', 'business'] + ); + + return response; } if (this.webCallingService.loginOption === LoginOption.BROWSER) { @@ -115,23 +165,16 @@ export default class Task extends EventEmitter implements IOldTask { ['operational', 'behavioral', 'business'] ); - return Promise.resolve(); // TODO: Update this with sending the task object received in AgentContactAssigned + LoggerProxy.log(`Task accepted successfully with webrtc calling`, { + module: TASK_FILE, + method: METHODS.ACCEPT, + interactionId: this.data.interactionId, + }); } - // TODO: Invoke the accept API from services layer. This is going to be used in Outbound Dialer scenario - const response = await this.contact.accept({interactionId: this.data.interactionId}); - this.metricsManager.trackEvent( - METRIC_EVENT_NAMES.TASK_ACCEPT_SUCCESS, - { - ...MetricsManager.getCommonTrackingFieldForAQMResponse(response), - taskId: this.data.interactionId, - }, - ['operational', 'behavioral', 'business'] - ); - - return response; + return Promise.resolve(); // TODO: reject for extension as part of refactor } catch (error) { - const {error: detailedError} = getErrorDetails(error, 'accept', CC_FILE); + const {error: detailedError} = getErrorDetails(error, METHODS.ACCEPT, TASK_FILE); this.metricsManager.trackEvent( METRIC_EVENT_NAMES.TASK_ACCEPT_FAILED, { @@ -146,8 +189,8 @@ export default class Task extends EventEmitter implements IOldTask { } /** - * This is used for the placing the call in mute or unmute by the agent. - * + * Agent can mute/unmute the webrtc task. + * @returns Promise - Resolves when mute/unmute operation completes * @throws Error * @example * ```typescript @@ -156,17 +199,32 @@ export default class Task extends EventEmitter implements IOldTask { */ public async toggleMute() { try { + LoggerProxy.info(`Toggling mute state`, { + module: TASK_FILE, + method: METHODS.TOGGLE_MUTE, + interactionId: this.data.interactionId, + }); + this.webCallingService.muteUnmuteCall(this.localAudioStream); + LoggerProxy.log( + `Mute state toggled successfully isCallMuted: ${this.webCallingService.isCallMuted()}`, + { + module: TASK_FILE, + method: METHODS.TOGGLE_MUTE, + interactionId: this.data.interactionId, + } + ); + return Promise.resolve(); } catch (error) { - const {error: detailedError} = getErrorDetails(error, 'mute', CC_FILE); + const {error: detailedError} = getErrorDetails(error, METHODS.TOGGLE_MUTE, TASK_FILE); throw detailedError; } } /** - * This is used for the incoming task decline by agent. + * Declines the incoming webrtc task. * * @returns Promise * @throws Error @@ -177,6 +235,11 @@ export default class Task extends EventEmitter implements IOldTask { */ public async decline(): Promise { try { + LoggerProxy.info(`Declining task`, { + module: TASK_FILE, + method: METHODS.DECLINE, + interactionId: this.data.interactionId, + }); this.metricsManager.timeEvent([ METRIC_EVENT_NAMES.TASK_DECLINE_SUCCESS, METRIC_EVENT_NAMES.TASK_DECLINE_FAILED, @@ -191,9 +254,15 @@ export default class Task extends EventEmitter implements IOldTask { ['operational', 'behavioral'] ); + LoggerProxy.log(`Task declined successfully`, { + module: TASK_FILE, + method: METHODS.DECLINE, + interactionId: this.data.interactionId, + }); + return Promise.resolve(); } catch (error) { - const {error: detailedError} = getErrorDetails(error, 'decline', CC_FILE); + const {error: detailedError} = getErrorDetails(error, METHODS.DECLINE, TASK_FILE); this.metricsManager.trackEvent( METRIC_EVENT_NAMES.TASK_DECLINE_FAILED, { @@ -208,7 +277,7 @@ export default class Task extends EventEmitter implements IOldTask { } /** - * This is used to hold the task. + * Puts the current task/interaction on hold. * @returns Promise * @throws Error * @example @@ -218,6 +287,12 @@ export default class Task extends EventEmitter implements IOldTask { * */ public async hold(): Promise { try { + LoggerProxy.info(`Holding task`, { + module: TASK_FILE, + method: METHODS.HOLD, + interactionId: this.data.interactionId, + }); + this.metricsManager.timeEvent([ METRIC_EVENT_NAMES.TASK_HOLD_SUCCESS, METRIC_EVENT_NAMES.TASK_HOLD_FAILED, @@ -238,9 +313,16 @@ export default class Task extends EventEmitter implements IOldTask { ['operational', 'behavioral'] ); + LoggerProxy.log(`Task placed on hold successfully`, { + module: TASK_FILE, + method: METHODS.HOLD, + trackingId: response.trackingId, + interactionId: this.data.interactionId, + }); + return response; } catch (error) { - const {error: detailedError} = getErrorDetails(error, 'hold', CC_FILE); + const {error: detailedError} = getErrorDetails(error, METHODS.HOLD, TASK_FILE); this.metricsManager.trackEvent( METRIC_EVENT_NAMES.TASK_HOLD_FAILED, { @@ -256,7 +338,7 @@ export default class Task extends EventEmitter implements IOldTask { } /** - * This is used to resume the task. + * Resumes the task/interaction that was previously put on hold. * @returns Promise * @throws Error * @example @@ -266,8 +348,14 @@ export default class Task extends EventEmitter implements IOldTask { */ public async resume(): Promise { try { + LoggerProxy.info(`Resuming task`, { + module: TASK_FILE, + method: METHODS.RESUME, + interactionId: this.data.interactionId, + }); const {mainInteractionId} = this.data.interaction; const {mediaResourceId} = this.data.interaction.media[mainInteractionId]; + this.metricsManager.timeEvent([ METRIC_EVENT_NAMES.TASK_RESUME_SUCCESS, METRIC_EVENT_NAMES.TASK_RESUME_FAILED, @@ -289,10 +377,17 @@ export default class Task extends EventEmitter implements IOldTask { ['operational', 'behavioral'] ); + LoggerProxy.log(`Task resumed successfully`, { + module: TASK_FILE, + method: METHODS.RESUME, + trackingId: response.trackingId, + interactionId: this.data.interactionId, + }); + return response; } catch (error) { - const {error: detailedError} = getErrorDetails(error, 'resume', CC_FILE); - const mainInteractionId = this.data?.interaction?.mainInteractionId; + const {error: detailedError} = getErrorDetails(error, METHODS.RESUME, TASK_FILE); + const mainInteractionId = this.data.interaction?.mainInteractionId; this.metricsManager.trackEvent( METRIC_EVENT_NAMES.TASK_RESUME_FAILED, { @@ -310,7 +405,7 @@ export default class Task extends EventEmitter implements IOldTask { } /** - * This is used to end the task. + * Ends the task/interaction with the customer. * @returns Promise * @throws Error * @example @@ -320,12 +415,19 @@ export default class Task extends EventEmitter implements IOldTask { */ public async end(): Promise { try { + LoggerProxy.info(`Ending task`, { + module: TASK_FILE, + method: METHODS.END, + interactionId: this.data.interactionId, + }); + this.metricsManager.timeEvent([ METRIC_EVENT_NAMES.TASK_END_SUCCESS, METRIC_EVENT_NAMES.TASK_END_FAILED, ]); const response = await this.contact.end({interactionId: this.data.interactionId}); + this.metricsManager.trackEvent( METRIC_EVENT_NAMES.TASK_END_SUCCESS, { @@ -335,9 +437,16 @@ export default class Task extends EventEmitter implements IOldTask { ['operational', 'behavioral', 'business'] ); + LoggerProxy.log(`Task ended successfully`, { + module: TASK_FILE, + method: METHODS.END, + trackingId: response.trackingId, + interactionId: this.data.interactionId, + }); + return response; } catch (error) { - const {error: detailedError} = getErrorDetails(error, 'end', CC_FILE); + const {error: detailedError} = getErrorDetails(error, METHODS.END, TASK_FILE); this.metricsManager.trackEvent( METRIC_EVENT_NAMES.TASK_END_FAILED, { @@ -351,10 +460,10 @@ export default class Task extends EventEmitter implements IOldTask { } /** - * This is used to wrap up the task. - * @param wrapupPayload - WrapupPayLoad + * Wraps up the task/interaction with the customer. + * @param wrapupPayload - WrapupPayLoad containing auxCodeId and wrapUpReason * @returns Promise - * @throws Error + * @throws Error - Throws if task data is unavailable, auxCodeId is missing, or wrapUpReason is missing * @example * ```typescript * task.wrapup(wrapupPayload).then(()=>{}).catch(()=>{}) @@ -362,6 +471,12 @@ export default class Task extends EventEmitter implements IOldTask { */ public async wrapup(wrapupPayload: WrapupPayLoad): Promise { try { + LoggerProxy.info(`Wrapping up task`, { + module: TASK_FILE, + method: METHODS.WRAPUP, + interactionId: this.data.interactionId, + }); + this.metricsManager.timeEvent([ METRIC_EVENT_NAMES.TASK_WRAPUP_SUCCESS, METRIC_EVENT_NAMES.TASK_WRAPUP_FAILED, @@ -393,9 +508,16 @@ export default class Task extends EventEmitter implements IOldTask { ['operational', 'behavioral', 'business'] ); + LoggerProxy.log(`Task wrapped up successfully`, { + module: TASK_FILE, + method: METHODS.WRAPUP, + trackingId: response.trackingId, + interactionId: this.data.interactionId, + }); + return response; } catch (error) { - const {error: detailedError} = getErrorDetails(error, 'wrapup', CC_FILE); + const {error: detailedError} = getErrorDetails(error, METHODS.WRAPUP, TASK_FILE); this.metricsManager.trackEvent( METRIC_EVENT_NAMES.TASK_WRAPUP_FAILED, { @@ -411,7 +533,7 @@ export default class Task extends EventEmitter implements IOldTask { } /** - * This is used to pause the call recording + * Pauses the recording for the current voice task. * @returns Promise * @throws Error * @example @@ -421,6 +543,12 @@ export default class Task extends EventEmitter implements IOldTask { */ public async pauseRecording(): Promise { try { + LoggerProxy.info(`Pausing recording`, { + module: TASK_FILE, + method: METHODS.PAUSE_RECORDING, + interactionId: this.data.interactionId, + }); + this.metricsManager.timeEvent([ METRIC_EVENT_NAMES.TASK_PAUSE_RECORDING_SUCCESS, METRIC_EVENT_NAMES.TASK_PAUSE_RECORDING_FAILED, @@ -437,9 +565,16 @@ export default class Task extends EventEmitter implements IOldTask { ['operational', 'behavioral', 'business'] ); + LoggerProxy.log(`Recording paused successfully`, { + module: TASK_FILE, + method: METHODS.PAUSE_RECORDING, + trackingId: result.trackingId, + interactionId: this.data.interactionId, + }); + return result; } catch (error) { - const {error: detailedError} = getErrorDetails(error, 'pauseRecording', CC_FILE); + const {error: detailedError} = getErrorDetails(error, METHODS.PAUSE_RECORDING, TASK_FILE); this.metricsManager.trackEvent( METRIC_EVENT_NAMES.TASK_PAUSE_RECORDING_FAILED, { @@ -454,8 +589,8 @@ export default class Task extends EventEmitter implements IOldTask { } /** - * This is used to pause the call recording - * @param resumeRecordingPayload + * Resumes the recording for the voice task that was previously paused. + * @param resumeRecordingPayload - Configuration for resuming recording, defaults to {autoResumed: false} * @returns Promise * @throws Error * @example @@ -467,6 +602,12 @@ export default class Task extends EventEmitter implements IOldTask { resumeRecordingPayload: ResumeRecordingPayload ): Promise { try { + LoggerProxy.info(`Resuming recording`, { + module: TASK_FILE, + method: METHODS.RESUME_RECORDING, + interactionId: this.data.interactionId, + }); + this.metricsManager.timeEvent([ METRIC_EVENT_NAMES.TASK_RESUME_RECORDING_SUCCESS, METRIC_EVENT_NAMES.TASK_RESUME_RECORDING_FAILED, @@ -488,9 +629,16 @@ export default class Task extends EventEmitter implements IOldTask { ['operational', 'behavioral', 'business'] ); + LoggerProxy.log(`Recording resumed successfully`, { + module: TASK_FILE, + method: METHODS.RESUME_RECORDING, + trackingId: result.trackingId, + interactionId: this.data.interactionId, + }); + return result; } catch (error) { - const {error: detailedError} = getErrorDetails(error, 'resumeRecording', CC_FILE); + const {error: detailedError} = getErrorDetails(error, METHODS.RESUME_RECORDING, TASK_FILE); this.metricsManager.trackEvent( METRIC_EVENT_NAMES.TASK_RESUME_RECORDING_FAILED, { @@ -505,8 +653,8 @@ export default class Task extends EventEmitter implements IOldTask { } /** - * This is used to consult the task - * @param consultPayload + * Consults another agent or queue on an onngoing task for further assistance. + * @param consultPayload - ConsultPayload containing destination and destinationType * @returns Promise * @throws Error * @example @@ -520,6 +668,12 @@ export default class Task extends EventEmitter implements IOldTask { * */ public async consult(consultPayload: ConsultPayload): Promise { try { + LoggerProxy.info(`Starting consult`, { + module: TASK_FILE, + method: METHODS.CONSULT, + interactionId: this.data.interactionId, + }); + this.metricsManager.timeEvent([ METRIC_EVENT_NAMES.TASK_CONSULT_START_SUCCESS, METRIC_EVENT_NAMES.TASK_CONSULT_START_FAILED, @@ -541,9 +695,16 @@ export default class Task extends EventEmitter implements IOldTask { ['operational', 'behavioral', 'business'] ); + LoggerProxy.log(`Consult started successfully to ${consultPayload.to}`, { + module: TASK_FILE, + method: METHODS.CONSULT, + trackingId: result.trackingId, + interactionId: this.data.interactionId, + }); + return result; } catch (error) { - const {error: detailedError} = getErrorDetails(error, 'consult', CC_FILE); + const {error: detailedError} = getErrorDetails(error, METHODS.CONSULT, TASK_FILE); this.metricsManager.trackEvent( METRIC_EVENT_NAMES.TASK_CONSULT_START_FAILED, { @@ -560,7 +721,7 @@ export default class Task extends EventEmitter implements IOldTask { } /** - * This is used to end the consult + * Ends the consult session in progress for a task. * @param consultEndPayload * @returns Promise * @throws Error @@ -575,6 +736,12 @@ export default class Task extends EventEmitter implements IOldTask { */ public async endConsult(consultEndPayload: ConsultEndPayload): Promise { try { + LoggerProxy.info(`Ending consult`, { + module: TASK_FILE, + method: METHODS.END_CONSULT, + interactionId: this.data.interactionId, + }); + this.metricsManager.timeEvent([ METRIC_EVENT_NAMES.TASK_CONSULT_END_SUCCESS, METRIC_EVENT_NAMES.TASK_CONSULT_END_FAILED, @@ -594,9 +761,16 @@ export default class Task extends EventEmitter implements IOldTask { ['operational', 'behavioral', 'business'] ); + LoggerProxy.log(`Consult ended successfully`, { + module: TASK_FILE, + method: METHODS.END_CONSULT, + trackingId: result.trackingId, + interactionId: this.data.interactionId, + }); + return result; } catch (error) { - const {error: detailedError} = getErrorDetails(error, 'endConsult', CC_FILE); + const {error: detailedError} = getErrorDetails(error, METHODS.END_CONSULT, TASK_FILE); this.metricsManager.trackEvent( METRIC_EVENT_NAMES.TASK_CONSULT_END_FAILED, { @@ -611,8 +785,8 @@ export default class Task extends EventEmitter implements IOldTask { } /** - * This is used to blind transfer or vTeam transfer the task - * @param transferPayload + * Transfer the task to an agent dierctly or to the queue. + * @param transferPayload - Transfer configuration containing destination and destination type * @returns Promise * @throws Error * @example @@ -626,6 +800,12 @@ export default class Task extends EventEmitter implements IOldTask { */ public async transfer(transferPayload: TransferPayLoad): Promise { try { + LoggerProxy.info(`Transferring task to ${transferPayload.to}`, { + module: TASK_FILE, + method: METHODS.TRANSFER, + interactionId: this.data.interactionId, + }); + this.metricsManager.timeEvent([ METRIC_EVENT_NAMES.TASK_TRANSFER_SUCCESS, METRIC_EVENT_NAMES.TASK_TRANSFER_FAILED, @@ -656,9 +836,16 @@ export default class Task extends EventEmitter implements IOldTask { ['operational', 'behavioral', 'business'] ); + LoggerProxy.log(`Task transferred successfully to ${transferPayload.to}`, { + module: TASK_FILE, + method: METHODS.TRANSFER, + trackingId: result.trackingId, + interactionId: this.data.interactionId, + }); + return result; } catch (error) { - const {error: detailedError} = getErrorDetails(error, 'transfer', CC_FILE); + const {error: detailedError} = getErrorDetails(error, METHODS.TRANSFER, TASK_FILE); this.metricsManager.trackEvent( METRIC_EVENT_NAMES.TASK_TRANSFER_FAILED, { @@ -676,15 +863,15 @@ export default class Task extends EventEmitter implements IOldTask { } /** - * This is used to consult transfer the task - * @param consultTransferPayload + * Transfer the task to the consulted agent or queue. + * @param consultTransferPayload - Consult transfer configuration containing destination and destinationType * @returns Promise * @throws Error * @example * ```typescript * const consultTransferPayload = { - * destination: 'anotherAgentId', - * destinationType: 'agent', + * destination: 'anotherAgentId | queueId', + * destinationType: 'agent | queue', * } * task.consultTransfer(consultTransferPayload).then(()=>{}).catch(()=>{}); * ``` @@ -693,10 +880,11 @@ export default class Task extends EventEmitter implements IOldTask { consultTransferPayload: ConsultTransferPayLoad ): Promise { try { - this.metricsManager.timeEvent([ - METRIC_EVENT_NAMES.TASK_TRANSFER_SUCCESS, - METRIC_EVENT_NAMES.TASK_TRANSFER_FAILED, - ]); + LoggerProxy.info(`Initiating consult transfer to ${consultTransferPayload.to}`, { + module: TASK_FILE, + method: METHODS.CONSULT_TRANSFER, + interactionId: this.data.interactionId, + }); // For queue destinations, use the destAgentId from task data if (consultTransferPayload.destinationType === CONSULT_TRANSFER_DESTINATION_TYPE.QUEUE) { @@ -728,9 +916,16 @@ export default class Task extends EventEmitter implements IOldTask { ['operational', 'behavioral', 'business'] ); + LoggerProxy.log(`Consult transfer completed successfully to ${consultTransferPayload.to}`, { + module: TASK_FILE, + method: METHODS.CONSULT_TRANSFER, + trackingId: result.trackingId, + interactionId: this.data.interactionId, + }); + return result; } catch (error) { - const {error: detailedError} = getErrorDetails(error, 'consultTransfer', CC_FILE); + const {error: detailedError} = getErrorDetails(error, METHODS.CONSULT_TRANSFER, TASK_FILE); this.metricsManager.trackEvent( METRIC_EVENT_NAMES.TASK_TRANSFER_FAILED, { diff --git a/packages/@webex/plugin-cc/src/services/task/types.ts b/packages/@webex/plugin-cc/src/services/task/types.ts index 272d34ae762..4b95e55e011 100644 --- a/packages/@webex/plugin-cc/src/services/task/types.ts +++ b/packages/@webex/plugin-cc/src/services/task/types.ts @@ -2,509 +2,1093 @@ import {CallId} from '@webex/calling/dist/types/common/types'; import EventEmitter from 'events'; import {Msg} from '../core/GlobalTypes'; +/** + * Unique identifier for a task in the contact center system + * @public + */ export type TaskId = string; +/** + * Helper type for creating enum-like objects with type safety + * @internal + */ type Enum> = T[keyof T]; +/** + * Defines the valid destination types for routing tasks within the contact center + * Used to specify where a task should be directed + * @public + */ export const DESTINATION_TYPE = { + /** Route task to a specific queue */ QUEUE: 'queue', + /** Route task to a specific dial number */ DIALNUMBER: 'dialNumber', + /** Route task to a specific agent */ AGENT: 'agent', - ENTRYPOINT: 'entryPoint', // Entrypoint is only supported for consult and not for transfer + /** Route task to an entry point (supported only for consult operations) */ + ENTRYPOINT: 'entryPoint', }; +/** + * Type representing valid destination types for task routing + * Derived from the DESTINATION_TYPE constant + * @public + */ export type DestinationType = Enum; +/** + * Defines the valid destination types for consult transfer operations + * Used when transferring a task after consultation + * @public + */ export const CONSULT_TRANSFER_DESTINATION_TYPE = { + /** Transfer to a specific agent */ AGENT: 'agent', + /** Transfer to an entry point */ ENTRYPOINT: 'entryPoint', + /** Transfer to a dial number */ DIALNUMBER: 'dialNumber', + /** Transfer to a queue */ QUEUE: 'queue', }; +/** + * Type representing valid destination types for consult transfers + * Derived from the CONSULT_TRANSFER_DESTINATION_TYPE constant + * @public + */ export type ConsultTransferDestinationType = Enum; +/** + * Defines all supported media channel types for customer interactions + * These represent the different ways customers can communicate with agents + * @public + */ export const MEDIA_CHANNEL = { + /** Email-based communication channel */ EMAIL: 'email', + /** Web-based chat communication channel */ CHAT: 'chat', + /** Voice/phone communication channel */ TELEPHONY: 'telephony', + /** Social media platform communication channel */ SOCIAL: 'social', + /** SMS text messaging communication channel */ SMS: 'sms', + /** Facebook Messenger communication channel */ FACEBOOK: 'facebook', + /** WhatsApp messaging communication channel */ WHATSAPP: 'whatsapp', } as const; +/** + * Type representing valid media channels + * Derived from the MEDIA_CHANNEL constant + * @public + */ export type MEDIA_CHANNEL = Enum; +/** + * Enumeration of all task-related events that can occur in the contact center system + * These events represent different states and actions in the task lifecycle + * @public + */ export enum TASK_EVENTS { + /** + * Triggered when a new task is received by the system + * @example + * ```typescript + * task.on(TASK_EVENTS.TASK_INCOMING, (task: ITask) => { + * console.log('New task received:', task.data.interactionId); + * // Handle incoming task + * }); + * ``` + */ TASK_INCOMING = 'task:incoming', + + /** + * Triggered when a task is successfully assigned to an agent + * @example + * ```typescript + * task.on(TASK_EVENTS.TASK_ASSIGNED, (task: ITask) => { + * console.log('Task assigned:', task.data.interactionId); + * // Begin handling the assigned task + * }); + * ``` + */ TASK_ASSIGNED = 'task:assigned', + + /** + * Triggered when the media state of a task changes + * @example + * ```typescript + * task.on(TASK_EVENTS.TASK_MEDIA, (track: MediaStreamTrack) => { + * // Handle media track updates + * }); + * ``` + */ TASK_MEDIA = 'task:media', + + /** + * Triggered when a task is removed from an agent + * @example + * ```typescript + * task.on(TASK_EVENTS.TASK_UNASSIGNED, (task: ITask) => { + * console.log('Task unassigned:', task.data.interactionId); + * // Clean up task resources + * }); + * ``` + */ TASK_UNASSIGNED = 'task:unassigned', + + /** + * Triggered when a task is placed on hold + * @example + * ```typescript + * task.on(TASK_EVENTS.TASK_HOLD, (task: ITask) => { + * console.log('Task placed on hold:', task.data.interactionId); + * // Update UI to show hold state + * }); + * ``` + */ TASK_HOLD = 'task:hold', + + /** + * Triggered when a task is resumed from hold + * @example + * ```typescript + * task.on(TASK_EVENTS.TASK_UNHOLD, (task: ITask) => { + * console.log('Task resumed from hold:', task.data.interactionId); + * // Update UI to show active state + * }); + * ``` + */ TASK_UNHOLD = 'task:unhold', + + /** + * Triggered when a consultation session ends + * @example + * ```typescript + * task.on(TASK_EVENTS.TASK_CONSULT_END, (task: ITask) => { + * console.log('Consultation ended:', task.data.interactionId); + * // Clean up consultation resources + * }); + * ``` + */ TASK_CONSULT_END = 'task:consultEnd', + + /** + * Triggered when a queue consultation is cancelled + * @example + * ```typescript + * task.on(TASK_EVENTS.TASK_CONSULT_QUEUE_CANCELLED, (task: ITask) => { + * console.log('Queue consultation cancelled:', task.data.interactionId); + * // Handle consultation cancellation + * }); + * ``` + */ TASK_CONSULT_QUEUE_CANCELLED = 'task:consultQueueCancelled', + + /** + * Triggered when a queue consultation fails + * @example + * ```typescript + * task.on(TASK_EVENTS.TASK_CONSULT_QUEUE_FAILED, (task: ITask) => { + * console.log('Queue consultation failed:', task.data.interactionId); + * // Handle consultation failure + * }); + * ``` + */ TASK_CONSULT_QUEUE_FAILED = 'task:consultQueueFailed', + + /** + * Triggered when a consultation request is accepted + * @example + * ```typescript + * task.on(TASK_EVENTS.TASK_CONSULT_ACCEPTED, (task: ITask) => { + * console.log('Consultation accepted:', task.data.interactionId); + * // Begin consultation + * }); + * ``` + */ TASK_CONSULT_ACCEPTED = 'task:consultAccepted', + + /** + * Triggered when consultation is in progress + * @example + * ```typescript + * task.on(TASK_EVENTS.TASK_CONSULTING, (task: ITask) => { + * console.log('Consulting in progress:', task.data.interactionId); + * // Handle ongoing consultation + * }); + * ``` + */ TASK_CONSULTING = 'task:consulting', + + /** + * Triggered when a new consultation is created + * @example + * ```typescript + * task.on(TASK_EVENTS.TASK_CONSULT_CREATED, (task: ITask) => { + * console.log('Consultation created:', task.data.interactionId); + * // Initialize consultation + * }); + * ``` + */ TASK_CONSULT_CREATED = 'task:consultCreated', + + /** + * Triggered when a consultation is offered + * @example + * ```typescript + * task.on(TASK_EVENTS.TASK_OFFER_CONSULT, (task: ITask) => { + * console.log('Consultation offered:', task.data.interactionId); + * // Handle consultation offer + * }); + * ``` + */ TASK_OFFER_CONSULT = 'task:offerConsult', - TASK_PAUSE = 'task:pause', - TASK_RESUME = 'task:resume', + + /** + * Triggered when a task is completed/terminated + * @example + * ```typescript + * task.on(TASK_EVENTS.TASK_END, (task: ITask) => { + * console.log('Task ended:', task.data.interactionId); + * // Clean up and finalize task + * }); + * ``` + */ TASK_END = 'task:end', + + /** + * Triggered when a task enters wrap-up state + * @example + * ```typescript + * task.on(TASK_EVENTS.TASK_WRAPUP, (task: ITask) => { + * console.log('Task in wrap-up:', task.data.interactionId); + * // Begin wrap-up process + * }); + * ``` + */ TASK_WRAPUP = 'task:wrapup', + + /** + * Triggered when task wrap-up is completed + * @example + * ```typescript + * task.on(TASK_EVENTS.TASK_WRAPPEDUP, (task: ITask) => { + * console.log('Task wrapped up:', task.data.interactionId); + * // Finalize task completion + * }); + * ``` + */ TASK_WRAPPEDUP = 'task:wrappedup', + + /** + * Triggered when recording is paused + * @example + * ```typescript + * task.on(TASK_EVENTS.TASK_RECORDING_PAUSED, (task: ITask) => { + * console.log('Recording paused:', task.data.interactionId); + * // Update recording state + * }); + * ``` + */ TASK_RECORDING_PAUSED = 'task:recordingPaused', + + /** + * Triggered when recording pause attempt fails + * @example + * ```typescript + * task.on(TASK_EVENTS.TASK_RECORDING_PAUSE_FAILED, (task: ITask) => { + * console.log('Recording pause failed:', task.data.interactionId); + * // Handle pause failure + * }); + * ``` + */ TASK_RECORDING_PAUSE_FAILED = 'task:recordingPauseFailed', + + /** + * Triggered when recording is resumed + * @example + * ```typescript + * task.on(TASK_EVENTS.TASK_RECORDING_RESUMED, (task: ITask) => { + * console.log('Recording resumed:', task.data.interactionId); + * // Update recording state + * }); + * ``` + */ TASK_RECORDING_RESUMED = 'task:recordingResumed', + + /** + * Triggered when recording resume attempt fails + * @example + * ```typescript + * task.on(TASK_EVENTS.TASK_RECORDING_RESUME_FAILED, (task: ITask) => { + * console.log('Recording resume failed:', task.data.interactionId); + * // Handle resume failure + * }); + * ``` + */ TASK_RECORDING_RESUME_FAILED = 'task:recordingResumeFailed', + + /** + * Triggered when a task is rejected/unanswered + * @example + * ```typescript + * task.on(TASK_EVENTS.TASK_REJECT, (task: ITask) => { + * console.log('Task rejected:', task.data.interactionId); + * // Handle task rejection + * }); + * ``` + */ TASK_REJECT = 'task:rejected', + + /** + * Triggered when a task is populated with data + * @example + * ```typescript + * task.on(TASK_EVENTS.TASK_HYDRATE, (task: ITask) => { + * console.log('Task hydrated:', task.data.interactionId); + * // Process task data + * }); + * ``` + */ TASK_HYDRATE = 'task:hydrate', + + /** + * Triggered when a new contact is offered + * @example + * ```typescript + * task.on(TASK_EVENTS.TASK_OFFER_CONTACT, (task: ITask) => { + * console.log('Contact offered:', task.data.interactionId); + * // Handle contact offer + * }); + * ``` + */ TASK_OFFER_CONTACT = 'task:offerContact', } +/** + * Represents a customer interaction within the contact center system + * Contains comprehensive details about an ongoing customer interaction + * @public + */ export type Interaction = { + /** Indicates if the interaction is managed by Flow Control */ isFcManaged: boolean; + /** Indicates if the interaction has been terminated */ isTerminated: boolean; + /** The type of media channel for this interaction */ mediaType: MEDIA_CHANNEL; + /** List of previous virtual teams that handled this interaction */ previousVTeams: string[]; + /** Current state of the interaction */ state: string; + /** Current virtual team handling the interaction */ currentVTeam: string; - participants: any; // todo + /** List of participants in the interaction */ + participants: any; // TODO: Define specific participant type + /** Unique identifier for the interaction */ interactionId: string; + /** Organization identifier */ orgId: string; + /** Timestamp when the interaction was created */ createdTimestamp?: number; + /** Indicates if wrap-up assistance is enabled */ isWrapUpAssist?: boolean; + /** Detailed call processing information and metadata */ callProcessingDetails: { + /** Name of the Queue Manager handling this interaction */ QMgrName: string; + /** Indicates if the task should be self-serviced */ taskToBeSelfServiced: string; + /** Automatic Number Identification (caller's number) */ ani: string; + /** Display version of the ANI */ displayAni: string; + /** Dialed Number Identification Service number */ dnis: string; + /** Tenant identifier */ tenantId: string; + /** Queue identifier */ QueueId: string; + /** Virtual team identifier */ vteamId: string; + /** Indicates if pause/resume functionality is enabled */ pauseResumeEnabled?: string; + /** Duration of pause in seconds */ pauseDuration?: string; + /** Indicates if the interaction is currently paused */ isPaused?: string; + /** Indicates if recording is in progress */ recordInProgress?: string; + /** Indicates if recording has started */ recordingStarted?: string; + /** Indicates if Consult to Queue is in progress */ ctqInProgress?: string; + /** Indicates if outdial transfer to queue is enabled */ outdialTransferToQueueEnabled?: string; + /** IVR conversation transcript */ convIvrTranscript?: string; + /** Customer's name */ customerName: string; + /** Name of the virtual team */ virtualTeamName: string; + /** RONA (Redirection on No Answer) timeout in seconds */ ronaTimeout: string; + /** Category of the interaction */ category: string; + /** Reason for the interaction */ reason: string; + /** Source number for the interaction */ sourceNumber: string; + /** Source page that initiated the interaction */ sourcePage: string; + /** Application user identifier */ appUser: string; + /** Customer's contact number */ customerNumber: string; + /** Code indicating the reason for interaction */ reasonCode: string; + /** Path taken through the IVR system */ IvrPath: string; + /** Identifier for the IVR path */ pathId: string; + /** Email address or contact point that initiated the interaction */ fromAddress: string; + /** Identifier of the parent interaction for related interactions */ parentInteractionId?: string; + /** Identifier of the child interaction for related interactions */ childInteractionId?: string; + /** Type of relationship between parent and child interactions */ relationshipType?: string; + /** ANI of the parent interaction */ parent_ANI?: string; + /** DNIS of the parent interaction */ parent_DNIS?: string; + /** Indicates if the consulted destination agent has joined */ consultDestinationAgentJoined?: boolean | string; + /** Name of the destination agent for consultation */ consultDestinationAgentName?: string; + /** DN of the parent interaction's agent */ parent_Agent_DN?: string; + /** Name of the parent interaction's agent */ parent_Agent_Name?: string; + /** Team name of the parent interaction's agent */ parent_Agent_TeamName?: string; + /** Indicates if the interaction is in conference mode */ isConferencing?: string; + /** Type of monitoring being performed */ monitorType?: string; + /** Name of the workflow being executed */ workflowName?: string; + /** Identifier of the workflow */ workflowId?: string; + /** Indicates if monitoring is in invisible mode */ monitoringInvisibleMode?: string; + /** Identifier for the monitoring request */ monitoringRequestId?: string; + /** Timeout for participant invitation */ participantInviteTimeout?: string; + /** Filename for music on hold */ mohFileName?: string; + /** Flag for continuing recording during transfer */ CONTINUE_RECORDING_ON_TRANSFER?: string; + /** Entry point identifier */ EP_ID?: string; + /** Type of routing being used */ ROUTING_TYPE?: string; + /** Events registered with Flow Control Engine */ fceRegisteredEvents?: string; + /** Indicates if the interaction is parked */ isParked?: string; + /** Priority level of the interaction */ priority?: string; + /** Identifier for the routing strategy */ routingStrategyId?: string; + /** Current state of monitoring */ monitoringState?: string; + /** Indicates if blind transfer is in progress */ BLIND_TRANSFER_IN_PROGRESS?: boolean; + /** Desktop view configuration for Flow Control */ fcDesktopView?: string; }; + /** Main interaction identifier for related interactions */ mainInteractionId?: string; + /** Media-specific information for the interaction */ media: Record< string, { + /** Unique identifier for the media resource */ mediaResourceId: string; + /** Type of media channel */ mediaType: MEDIA_CHANNEL; + /** Media manager handling this media */ mediaMgr: string; + /** List of participant identifiers */ participants: string[]; + /** Type of media */ mType: string; + /** Indicates if media is on hold */ isHold: boolean; + /** Timestamp when media was put on hold */ holdTimestamp: number | null; } >; + /** Owner of the interaction */ owner: string; + /** Primary media channel for the interaction */ mediaChannel: MEDIA_CHANNEL; + /** Direction information for the contact */ contactDirection: {type: string}; + /** Type of outbound interaction */ outboundType?: string; + /** Parameters passed through the call flow */ callFlowParams: Record< string, { + /** Name of the parameter */ name: string; + /** Qualifier for the parameter */ qualifier: string; + /** Description of the parameter */ description: string; + /** Data type of the parameter value */ valueDataType: string; + /** Value of the parameter */ value: string; } >; }; /** - * Task payload type + * Task payload containing detailed information about a contact center task + * This structure encapsulates all relevant data for task management + * @public */ export type TaskData = { + /** Unique identifier for the media resource handling this task */ mediaResourceId: string; + /** Type of event that triggered this task data */ eventType: string; + /** Timestamp when the event occurred */ eventTime?: number; + /** Identifier of the agent handling the task */ agentId: string; + /** Identifier of the destination agent for transfers/consults */ destAgentId: string; + /** Unique tracking identifier for the task */ trackingId: string; + /** Media resource identifier for consultation operations */ consultMediaResourceId: string; + /** Detailed interaction information */ interaction: Interaction; + /** Unique identifier for the participant */ participantId?: string; + /** Indicates if the task is from the owner */ fromOwner?: boolean; + /** Indicates if the task is to the owner */ toOwner?: boolean; + /** Identifier for child interaction in consult/transfer scenarios */ childInteractionId?: string; + /** Unique identifier for the interaction */ interactionId: string; + /** Organization identifier */ orgId: string; + /** Current owner of the task */ owner: string; + /** Queue manager handling the task */ queueMgr: string; + /** Name of the queue where task is queued */ queueName?: string; + /** Type of the task */ type: string; + /** Timeout value for RONA (Redirection on No Answer) in seconds */ ronaTimeout?: number; + /** Indicates if the task is in consultation state */ isConsulted?: boolean; + /** Indicates if the task is in conference state */ isConferencing: boolean; + /** Identifier of agent who last updated the task */ updatedBy?: string; + /** Type of destination for transfer/consult */ destinationType?: string; + /** Indicates if the task was automatically resumed */ autoResumed?: boolean; + /** Code indicating the reason for an action */ reasonCode?: string | number; + /** Description of the reason for an action */ reason?: string; + /** Identifier of the consulting agent */ consultingAgentId?: string; + /** Unique identifier for the task */ taskId?: string; + /** Task details including state and media information */ task?: Interaction; - id?: string; // unique id in monitoring offered event + /** Unique identifier for monitoring offered events */ + id?: string; + /** Indicates if the web call is muted */ isWebCallMute?: boolean; + /** Identifier for reservation interaction */ reservationInteractionId?: string; + /** Indicates if wrap-up is required for this task */ wrapUpRequired?: boolean; }; +/** + * Type representing an agent contact message within the contact center system + * Contains comprehensive interaction and task related details for agent operations + * @public + */ export type AgentContact = Msg<{ + /** Unique identifier for the media resource */ mediaResourceId: string; + /** Type of the event (e.g., 'AgentDesktopMessage') */ eventType: string; + /** Timestamp when the event occurred */ eventTime?: number; + /** Unique identifier of the agent handling the contact */ agentId: string; + /** Identifier of the destination agent for transfers/consults */ destAgentId: string; + /** Unique tracking identifier for the contact */ trackingId: string; + /** Media resource identifier for consult operations */ consultMediaResourceId: string; + /** Detailed interaction information including media and participant data */ interaction: Interaction; + /** Unique identifier for the participant */ participantId?: string; + /** Indicates if the message is from the owner of the interaction */ fromOwner?: boolean; + /** Indicates if the message is to the owner of the interaction */ toOwner?: boolean; + /** Identifier for child interaction in case of consult/transfer */ childInteractionId?: string; + /** Unique identifier for the interaction */ interactionId: string; + /** Organization identifier */ orgId: string; + /** Current owner of the interaction */ owner: string; + /** Queue manager handling the interaction */ queueMgr: string; + /** Name of the queue where interaction is queued */ queueName?: string; + /** Type of the contact/interaction */ type: string; + /** Timeout value for RONA (Redirection on No Answer) in seconds */ ronaTimeout?: number; + /** Indicates if the interaction is in consult state */ isConsulted?: boolean; + /** Indicates if the interaction is in conference state */ isConferencing: boolean; + /** Identifier of the agent who last updated the interaction */ updatedBy?: string; + /** Type of destination for transfer/consult */ destinationType?: string; + /** Indicates if the interaction was automatically resumed */ autoResumed?: boolean; + /** Code indicating the reason for an action */ reasonCode?: string | number; + /** Description of the reason for an action */ reason?: string; + /** Identifier of the consulting agent */ consultingAgentId?: string; + /** Unique identifier for the task */ taskId?: string; + /** Task details including media and state information */ task?: Interaction; + /** Identifier of the supervisor monitoring the interaction */ supervisorId?: string; + /** Type of monitoring (e.g., 'SILENT', 'BARGE_IN') */ monitorType?: string; + /** Dial number of the supervisor */ supervisorDN?: string; - id?: string; // unique id in monitoring offered event + /** Unique identifier for monitoring offered events */ + id?: string; + /** Indicates if the web call is muted */ isWebCallMute?: boolean; + /** Identifier for reservation interaction */ reservationInteractionId?: string; + /** Identifier for the reserved agent channel */ reservedAgentChannelId?: string; + /** Current monitoring state information */ monitoringState?: { + /** Type of monitoring state */ type: string; }; + /** Name of the supervisor monitoring the interaction */ supervisorName?: string; }>; +/** + * Information about a virtual team in the contact center + * @ignore + */ export type VTeam = { + /** Profile ID of the agent in the virtual team */ agentProfileId: string; + /** Session ID of the agent in the virtual team */ agentSessionId: string; + /** Type of channel handled by the virtual team */ channelType: string; + /** Type of the virtual team */ type: string; + /** Optional tracking identifier */ trackingId?: string; }; +/** + * Detailed information about a virtual team configuration + * @ignore + */ export type VteamDetails = { + /** Name of the virtual team */ name: string; + /** Type of channel handled by the virtual team */ channelType: string; + /** Unique identifier for the virtual team */ id: string; + /** Type of the virtual team */ type: string; + /** ID of the analyzer associated with the team */ analyzerId: string; }; +/** + * Response type for successful virtual team operations + * Contains details about virtual teams and their capabilities + * @ignore + */ export type VTeamSuccess = Msg<{ + /** Response data containing team information */ data: { + /** List of virtual team details */ vteamList: Array; + /** Whether queue consultation is allowed */ allowConsultToQueue: boolean; }; + /** Method name from JavaScript */ jsMethod: string; + /** Data related to the call */ callData: string; + /** Session ID of the agent */ agentSessionId: string; }>; /** - * Parameters to be passed for pause recording task + * Parameters for putting a task on hold or resuming from hold + * @public */ export type HoldResumePayload = { + /** Unique identifier for the media resource to hold/resume */ mediaResourceId: string; }; /** - * Parameters to be passed for resume recording task + * Parameters for resuming a task's recording + * @public */ export type ResumeRecordingPayload = { + /** Indicates if the recording was automatically resumed */ autoResumed: boolean; }; /** - * Parameters to be passed for transfer task + * Parameters for transferring a task to another destination + * @public */ export type TransferPayLoad = { + /** Destination identifier where the task will be transferred to */ to: string; + /** Type of the destination (queue, agent, etc.) */ destinationType: DestinationType; }; /** - * Parameters to be passed for transfer task + * Parameters for initiating a consultative transfer + * @public */ export type ConsultTransferPayLoad = { + /** Destination identifier for the consultation transfer */ to: string; + /** Type of the consultation transfer destination */ destinationType: ConsultTransferDestinationType; }; /** - * Parameters to be passed for consult task + * Parameters for initiating a consultation with another agent or queue + * @public */ export type ConsultPayload = { + /** Destination identifier for the consultation */ to: string | undefined; + /** Type of the consultation destination (agent, queue, etc.) */ destinationType: DestinationType; - holdParticipants?: boolean; // This value is assumed to be always true irrespective of the value passed in the API + /** Whether to hold other participants during consultation (always true) */ + holdParticipants?: boolean; }; /** - * Parameters to be passed for consult end task + * Parameters for ending a consultation task + * @public */ export type ConsultEndPayload = { + /** Indicates if this is a consultation operation */ isConsult: boolean; + /** Indicates if this involves a secondary entry point or DN agent */ isSecondaryEpDnAgent?: boolean; - queueId?: string; // Dev portal API docs state that it requires queueId, but it's optional in Desktop usage + /** Optional queue identifier for the consultation */ + queueId?: string; + /** Identifier of the task being consulted */ taskId: string; }; /** - * Parameters to be passed for transfer task + * Parameters for transferring a task to another destination + * @public */ export type TransferPayload = { + /** Destination identifier where the task will be transferred */ to: string | undefined; + /** Type of the transfer destination */ destinationType: DestinationType; }; /** - * Parameters to be passed for consult end task + * API payload for ending a consultation * This is the actual payload that is sent to the developer API + * @public */ export type ConsultEndAPIPayload = { + /** Optional identifier of the queue involved in the consultation */ queueId?: string; }; +/** + * Data required for consulting and conferencing operations + * @public + */ export type ConsultConferenceData = { + /** Identifier of the agent initiating consult/conference */ agentId?: string; + /** Target destination for the consult/conference */ to: string | undefined; + /** Type of destination (e.g., 'agent', 'queue') */ destinationType: string; }; +/** + * Parameters required for cancelling a consult to queue operation + * @public + */ export type cancelCtq = { + /** Identifier of the agent cancelling the CTQ */ agentId: string; + /** Identifier of the queue where consult was initiated */ queueId: string; }; +/** + * Parameters required for declining a task + * @public + */ export type declinePayload = { + /** Identifier of the media resource to decline */ mediaResourceId: string; }; /** - * Parameters to be passed for wrapup task + * Parameters for wrapping up a task with relevant completion details + * @public */ export type WrapupPayLoad = { + /** The reason provided for wrapping up the task */ wrapUpReason: string; + /** Auxiliary code identifier associated with the wrap-up state */ auxCodeId: string; }; /** - * Parameters to be passed for outbound dialer task + * Configuration parameters for initiating outbound dialer tasks + * @public */ export type DialerPayload = { - /** - * An entryPointId for respective task. - */ + /** An entryPointId for respective task */ entryPointId: string; - /** - * A valid customer DN, on which the response is expected, maximum length 36 characters. - */ + /** A valid customer DN, on which the response is expected, maximum length 36 characters */ destination: string; - - /** - * The direction of the call. - */ + /** The direction of the call */ direction: 'OUTBOUND'; - - /** - * This is a schema free data tuple to pass-on specific data, depending on the outboundType. Supports a maximum of 30 tuples. - */ + /** Schema-free data tuples to pass specific data based on outboundType (max 30 tuples) */ attributes: {[key: string]: string}; - - /** - * The media type for the request. - */ + /** The media type for the request */ mediaType: 'telephony' | 'chat' | 'social' | 'email'; - - /** - * The outbound type for the task. - */ + /** The outbound type for the task */ outboundType: 'OUTDIAL' | 'CALLBACK' | 'EXECUTE_FLOW'; }; +/** + * Data structure for cleaning up contact resources + * @public + */ export type ContactCleanupData = { + /** Type of cleanup operation being performed */ type: string; + /** Organization identifier where cleanup is occurring */ orgId: string; + /** Identifier of the agent associated with the contacts */ agentId: string; + /** Detailed data about the cleanup operation */ data: { + /** Type of event that triggered the cleanup */ eventType: string; + /** Identifier of the interaction being cleaned up */ interactionId: string; + /** Organization identifier */ orgId: string; + /** Media manager handling the cleanup */ mediaMgr: string; + /** Tracking identifier for the cleanup operation */ trackingId: string; + /** Type of media being cleaned up */ mediaType: string; + /** Optional destination information */ destination?: string; + /** Whether this is a broadcast cleanup */ broadcast: boolean; + /** Type of cleanup being performed */ type: string; }; }; /** - * Response type for the task public methods + * Response type for task public methods + * Can be an {@link AgentContact} object containing updated task state, + * an Error in case of failure, or void for operations that don't return data + * @public */ export type TaskResponse = AgentContact | Error | void; -// TODO: Remove this interface when we have a proper refactor of the Task com,pleted +/** + * Represents an interface for managing task related operations. + */ export interface IOldTask extends EventEmitter { /** - * Event data received in the CC events + * Event data received in the Contact Center events. + * Contains detailed task information including interaction details, media resources, + * and participant data as defined in {@link TaskData} */ data: TaskData; + /** - * Map of task with call + * Map associating tasks with their corresponding call identifiers. */ webCallMap: Record; + /** - * Switch off the call listeners + * Deregisters all web call event listeners + * Used when cleaning up task resources + * @ignore */ unregisterWebCallListeners(): void; + /** - * Used to update the task when the data received on each event + * Updates the task data with new information + * @param newData - Updated task data to apply, must conform to {@link TaskData} structure + * @returns Updated task instance + * @ignore */ updateTaskData(newData: TaskData): IOldTask; /** - * Answers/accepts the incoming task - * + * Answers or accepts an incoming task. + * Once accepted, the task will be assigned to the agent and trigger a {@link TASK_EVENTS.TASK_ASSIGNED} event. + * The response will contain updated agent contact information as defined in {@link AgentContact}. + * @returns Promise * @example - * ``` + * ```typescript * task.accept(); * ``` */ accept(): Promise; + /** - * Decline the incoming task for Browser Login - * + * Declines an incoming task for Browser Login + * @returns Promise * @example - * ``` + * ```typescript * task.decline(); * ``` */ decline(): Promise; + /** - * This is used to hold the task. + * Places the current task on hold * @returns Promise * @example - * ``` + * ```typescript * task.hold(); * ``` */ hold(): Promise; + /** - * This is used to resume the task. + * Resumes a task that was previously on hold * @returns Promise * @example - * ``` + * ```typescript * task.resume(); * ``` */ resume(): Promise; + /** - * This is used to end the task. + * Ends/terminates the current task * @returns Promise * @example - * ``` + * ```typescript * task.end(); * ``` */ end(): Promise; + /** - * This is used to wrap up the task. - * @param wrapupPayload + * Initiates wrap-up process for the task with specified details + * @param wrapupPayload - Wrap-up details including reason and auxiliary code * @returns Promise * @example - * ``` - * task.wrapup(data); + * ```typescript + * task.wrapup({ + * wrapUpReason: "Customer issue resolved", + * auxCodeId: "RESOLVED" + * }); * ``` */ wrapup(wrapupPayload: WrapupPayLoad): Promise; + /** - * This is used to pause the call recording. + * Pauses the recording for current task * @returns Promise * @example - * ``` - * task.wrapup(); + * ```typescript + * task.pauseRecording(); * ``` */ pauseRecording(): Promise; + /** - * This is used to resume the call recording. - * @param resumeRecordingPayload + * Resumes a previously paused recording + * @param resumeRecordingPayload - Parameters for resuming the recording * @returns Promise * @example - * ``` - * task.resumeRecording(); + * ```typescript + * task.resumeRecording({ + * autoResumed: false + * }); * ``` */ resumeRecording(resumeRecordingPayload: ResumeRecordingPayload): Promise; diff --git a/packages/@webex/plugin-cc/src/types.ts b/packages/@webex/plugin-cc/src/types.ts index ea6fb7c644e..8b9ed657bde 100644 --- a/packages/@webex/plugin-cc/src/types.ts +++ b/packages/@webex/plugin-cc/src/types.ts @@ -8,247 +8,521 @@ import * as Agent from './services/agent/types'; import * as Contact from './services/task/types'; import {Profile} from './services/config/types'; +/** + * Generic type for converting a const enum object into a union type of its values. + * @template T The enum object type + * @internal + * @ignore + */ type Enum> = T[keyof T]; -// Define the HTTP_METHODS object +/** + * HTTP methods supported by WebexRequest. + * @enum {string} + * @public + * @example + * const method: HTTP_METHODS = HTTP_METHODS.GET; + * @ignore + */ export const HTTP_METHODS = { + /** HTTP GET method for retrieving data */ GET: 'GET', + /** HTTP POST method for creating resources */ POST: 'POST', + /** HTTP PATCH method for partial updates */ PATCH: 'PATCH', + /** HTTP PUT method for complete updates */ PUT: 'PUT', + /** HTTP DELETE method for removing resources */ DELETE: 'DELETE', } as const; -// Derive the type using the utility type +/** + * Union type of HTTP methods. + * @public + * @example + * function makeRequest(method: HTTP_METHODS) { ... } + * @ignore + */ export type HTTP_METHODS = Enum; +/** + * Payload for making requests to Webex APIs. + * @public + * @example + * const payload: WebexRequestPayload = { + * service: 'identity', + * resource: '/users', + * method: HTTP_METHODS.GET + * }; + * @ignore + */ export type WebexRequestPayload = { + /** Service name to target */ service?: string; + /** Resource path within the service */ resource?: string; + /** HTTP method to use */ method?: HTTP_METHODS; + /** Full URI if not using service/resource pattern */ uri?: string; + /** Whether to add authorization header */ addAuthHeader?: boolean; + /** Custom headers to include in request */ headers?: { [key: string]: string | null; }; + /** Request body data */ body?: object; + /** Expected response status code */ statusCode?: number; + /** Whether to parse response as JSON */ json?: boolean; }; +/** + * Event listener function type. + * @internal + * @ignore + */ type Listener = (e: string, data?: unknown) => void; + +/** + * Event listener removal function type. + * @internal + * @ignore + */ type ListenerOff = (e: string) => void; +/** + * Service host configuration. + * @internal + * @ignore + */ type ServiceHost = { + /** Host URL/domain for the service */ host: string; + /** Time-to-live in seconds */ ttl: number; + /** Priority level for load balancing (lower is higher priority) */ priority: number; + /** Unique identifier for this host */ id: string; + /** Whether this is the home cluster for the user */ homeCluster?: boolean; }; +/** + * Configuration options for the Contact Center Plugin. + * @interface CCPluginConfig + * @public + * @example + * const config: CCPluginConfig = { + * allowMultiLogin: true, + * allowAutomatedRelogin: false, + * clientType: 'browser', + * isKeepAliveEnabled: true, + * force: false, + * metrics: { clientName: 'myClient', clientType: 'browser' }, + * logging: { enable: true, verboseEvents: false }, + * callingClientConfig: { ... } + * }; + */ export interface CCPluginConfig { + /** Whether to allow multiple logins from different devices */ allowMultiLogin: boolean; + /** Whether to automatically attempt relogin on connection loss */ allowAutomatedRelogin: boolean; + /** The type of client making the connection */ clientType: string; + /** Whether to enable keep-alive messages */ isKeepAliveEnabled: boolean; + /** Whether to force registration */ force: boolean; + /** Metrics configuration */ metrics: { + /** Name of the client for metrics */ clientName: string; + /** Type of client for metrics */ clientType: string; }; + /** Logging configuration */ logging: { + /** Whether to enable logging */ enable: boolean; + /** Whether to log verbose events */ verboseEvents: boolean; }; + /** Configuration for the calling client */ callingClientConfig: CallingClientConfig; } /** - * Logging related types + * Logger interface for standardized logging throughout the plugin. + * @public + * @example + * logger.log('This is a log message'); + * logger.error('This is an error message'); + * @ignore */ export type Logger = { + /** Log general messages */ log: (payload: string) => void; + /** Log error messages */ error: (payload: string) => void; + /** Log warning messages */ warn: (payload: string) => void; + /** Log informational messages */ info: (payload: string) => void; + /** Log detailed trace messages */ trace: (payload: string) => void; + /** Log debug messages */ debug: (payload: string) => void; }; +/** + * Contextual information for log entries. + * @public + * @ignore + */ export interface LogContext { + /** Module name where the log originated */ module?: string; + /** Method name where the log originated */ method?: string; + interactionId?: string; + trackingId?: string; } +/** + * Available logging severity levels. + * @enum {string} + * @public + * @example + * const level: LOGGING_LEVEL = LOGGING_LEVEL.error; + * @ignore + */ export enum LOGGING_LEVEL { + /** Critical failures that require immediate attention */ error = 'ERROR', + /** Important issues that don't prevent the system from working */ warn = 'WARN', + /** General informational logs */ log = 'LOG', + /** Detailed information about system operation */ info = 'INFO', + /** Highly detailed diagnostic information */ trace = 'TRACE', } +/** + * Metadata for log uploads. + * @public + * @example + * const meta: LogsMetaData = { feedbackId: 'fb123', correlationId: 'corr456' }; + * @ignore + */ export type LogsMetaData = { + /** Optional feedback ID to associate with logs */ feedbackId?: string; + /** Optional correlation ID to track related operations */ correlationId?: string; }; +/** + * Response from uploading logs to the server. + * @public + * @example + * const response: UploadLogsResponse = { trackingid: 'track123', url: 'https://...', userId: 'user1' }; + * @ignore + */ export type UploadLogsResponse = { + /** Tracking ID for the upload request */ trackingid?: string; + /** URL where the logs can be accessed */ url?: string; + /** ID of the user who uploaded logs */ userId?: string; + /** Feedback ID associated with the logs */ feedbackId?: string; + /** Correlation ID for tracking related operations */ correlationId?: string; }; + +/** + * Internal Webex SDK interfaces needed for plugin integration. + * @internal + * @ignore + */ interface IWebexInternal { + /** Mercury service for real-time messaging */ mercury: { + /** Register an event listener */ on: Listener; + /** Remove an event listener */ off: ListenerOff; + /** Establish a connection to the Mercury service */ connect: () => Promise; + /** Disconnect from the Mercury service */ disconnect: () => Promise; + /** Whether Mercury is currently connected */ connected: boolean; + /** Whether Mercury is in the process of connecting */ connecting: boolean; }; + /** Device information */ device: { + /** Current WDM URL */ url: string; + /** Current user's ID */ userId: string; + /** Current organization ID */ orgId: string; + /** Device version */ version: string; + /** Calling behavior configuration */ callingBehavior: string; }; + /** Presence service */ presence: unknown; + /** Services discovery and management */ services: { + /** Get a service URL by name */ get: (service: string) => string; + /** Wait for service catalog to be loaded */ waitForCatalog: (service: string) => Promise; + /** Host catalog for service discovery */ _hostCatalog: Record; + /** Service URLs cache */ _serviceUrls: { + /** Mobius calling service */ mobius: string; + /** Identity service */ identity: string; + /** Janus media server */ janus: string; + /** WDM (WebEx Device Management) service */ wdm: string; + /** BroadWorks IDP proxy service */ broadworksIdpProxy: string; + /** Hydra API service */ hydra: string; + /** Mercury API service */ mercuryApi: string; + /** UC Management gateway service */ 'ucmgmt-gateway': string; + /** Contacts service */ contactsService: string; }; }; + /** Metrics collection services */ newMetrics: { + /** Submit behavioral events (user actions) */ submitBehavioralEvent: SubmitBehavioralEvent; + /** Submit operational events (system operations) */ submitOperationalEvent: SubmitOperationalEvent; + /** Submit business events (business outcomes) */ submitBusinessEvent: SubmitBusinessEvent; }; + /** Support functionality */ support: { + /** Submit logs to server */ submitLogs: ( metaData: LogsMetaData, logs: string, options: { + /** Whether to submit full logs or just differences */ type: 'diff' | 'full'; } ) => Promise; }; } + +/** + * Interface representing the WebexSDK core functionality. + * @interface WebexSDK + * @public + * @example + * const sdk: WebexSDK = ...; + * sdk.request({ service: 'identity', resource: '/users', method: HTTP_METHODS.GET }); + * @ignore + */ export interface WebexSDK { + /** Version of the WebexSDK */ version: string; + /** Whether the SDK can authorize requests */ canAuthorize: boolean; + /** Credentials management */ credentials: { + /** Get the user token for authentication */ getUserToken: () => Promise; + /** Get the organization ID */ getOrgId: () => string; }; + /** Whether the SDK is ready for use */ ready: boolean; + /** Make a request to the Webex APIs */ request: (payload: WebexRequestPayload) => Promise; + /** Register a one-time event handler */ once: (event: string, callBack: () => void) => void; - // internal plugins + /** Internal plugins and services */ internal: IWebexInternal; - // public plugins + /** Logger instance */ logger: Logger; } /** * An interface for the `ContactCenter` class. * The `ContactCenter` package is designed to provide a set of APIs to perform various operations for the Agent flow within Webex Contact Center. + * @public + * @example + * const cc: IContactCenter = ...; + * cc.register().then(profile => { ... }); + * @ignore */ export interface IContactCenter { /** - * This will be public API used for making the CC SDK ready by setting up the cc mercury connection. + * Initialize the CC SDK by setting up the contact center mercury connection. + * This establishes WebSocket connectivity for real-time communication. + * + * @returns A Promise that resolves to the agent's profile upon successful registration + * @public + * @example + * cc.register().then(profile => { ... }); */ register(): Promise; } +/** + * Generic HTTP response structure. + * @public + * @example + * const response: IHttpResponse = { body: {}, statusCode: 200, method: 'GET', headers: {}, url: '...' }; + * @ignore + */ export interface IHttpResponse { + /** Response body content */ body: any; + /** HTTP status code */ statusCode: number; + /** HTTP method used for the request */ method: string; + /** Response headers */ headers: Headers; + /** Request URL */ url: string; } +/** + * Supported login options for agent authentication. + * @public + * @example + * const option: LoginOption = LoginOption.AGENT_DN; + * @ignore + */ export const LoginOption = { + /** Login using agent's direct number */ AGENT_DN: 'AGENT_DN', + /** Login using an extension number */ EXTENSION: 'EXTENSION', + /** Login using browser WebRTC capabilities */ BROWSER: 'BROWSER', } as const; -// Derive the type using the utility type +/** + * Union type of login options. + * @public + * @example + * function login(option: LoginOption) { ... } + * @ignore + */ export type LoginOption = Enum; +/** + * Request payload for subscribing to the contact center websocket. + * @public + * @example + * const req: SubscribeRequest = { force: true, isKeepAliveEnabled: true, clientType: 'browser', allowMultiLogin: false }; + * @ignore + */ export type SubscribeRequest = { + /** Whether to force connection even if another exists */ force: boolean; + /** Whether to send keepalive messages */ isKeepAliveEnabled: boolean; + /** Type of client connecting */ clientType: string; + /** Whether to allow login from multiple devices */ allowMultiLogin: boolean; }; /** * Represents the response from getListOfTeams method. - * + * Teams are groups of agents that can be managed together. * @public + * @example + * const team: Team = { id: 'team1', name: 'Support', desktopLayoutId: 'layout1' }; + * @ignore */ export type Team = { /** - * ID of the team. + * Unique identifier of the team. */ id: string; /** - * Name of the Team. + * Display name of the team. */ name: string; /** - * desktopLayoutId of the Team. + * Associated desktop layout ID for the team. + * Controls how the agent desktop is displayed for team members. */ desktopLayoutId?: string; }; /** - * Represents the request to a AgentLogin - * + * Represents the request to perform agent login. * @public + * @example + * const login: AgentLogin = { dialNumber: '1234', teamId: 'team1', loginOption: LoginOption.AGENT_DN }; + * @ignore */ export type AgentLogin = { /** * A dialNumber field contains the number to dial such as a route point or extension. + * Required for AGENT_DN and EXTENSION login options. */ - dialNumber?: string; /** * The unique ID representing a team of users. + * The agent must belong to this team. */ - teamId: string; /** - * The loginOption field contains the type of login. + * The loginOption field specifies the type of login method. + * Controls how calls are delivered to the agent. */ - loginOption: LoginOption; }; -export type AgentDeviceUpdate = Pick; +/** + * Represents the request to update agent profile settings. + * @public + * @example + * const update: AgentProfileUpdate = { loginOption: LoginOption.BROWSER, dialNumber: '5678' }; + * @ignore + */ +export type AgentProfileUpdate = Pick; +/** + * Union type for all possible request body types. + * @internal + * @ignore + */ export type RequestBody = | SubscribeRequest | Agent.Logout @@ -267,49 +541,118 @@ export type RequestBody = /** * Represents the options to fetch buddy agents for the logged in agent. + * Buddy agents are other agents who can be consulted or transfered to. * @public + * @example + * const opts: BuddyAgents = { mediaType: 'telephony', state: 'Available' }; + * @ignore */ export type BuddyAgents = { /** - * The media type for the request. The supported values are telephony, chat, social and email. + * The media type channel to filter buddy agents. + * Determines which channel capability the returned agents must have. */ mediaType: 'telephony' | 'chat' | 'social' | 'email'; + /** - * It represents the current state of the returned agents which can be either Available or Idle. - * If state is omitted, the API will return a list of both available and idle agents. - * This is useful for consult scenarios, since consulting an idle agent is also supported. + * Optional filter for agent state. + * If specified, returns only agents in that state. + * If omitted, returns both available and idle agents. */ state?: 'Available' | 'Idle'; }; /** - * Generic CC SDK error containing structured details. - * details.data can be any structured object. + * Holds the configuration flags for the Agent. + * These flags determine the availability of certain features in the Agent UI. + * @internal + */ +export type ConfigFlags = { + isEndCallEnabled: boolean; + isEndConsultEnabled: boolean; + webRtcEnabled: boolean; + autoWrapup: boolean; +}; + +/** + * Generic error structure for Contact Center SDK errors. + * Contains detailed information about the error context. + * @public + * @example + * const err: GenericError = new Error('Failed'); + * err.details = { type: 'ERR', orgId: 'org1', trackingId: 'track1', data: {} }; + * @ignore */ export interface GenericError extends Error { + /** Structured details about the error */ details: { + /** Error type identifier */ type: string; + /** Organization ID where the error occurred */ orgId: string; + /** Unique tracking ID for the error */ trackingId: string; + /** Additional error context data */ data: Record; }; } /** - * Holds the configuration flags for the Agent. - * These flags determine the availability of certain features in the Agent UI. - * @internal + * Response type for station login operations. + * Either a success response with agent details or an error. + * @public + * @example + * function handleLogin(resp: StationLoginResponse) { ... } + * @ignore */ -export type ConfigFlags = { - isEndCallEnabled: boolean; - isEndConsultEnabled: boolean; - webRtcEnabled: boolean; - autoWrapup: boolean; -}; - export type StationLoginResponse = Agent.StationLoginSuccessResponse | Error; + +/** + * Response type for station logout operations. + * Either a success response with logout details or an error. + * @public + * @example + * function handleLogout(resp: StationLogoutResponse) { ... } + * @ignore + */ export type StationLogoutResponse = Agent.LogoutSuccess | Error; + +/** + * Response type for station relogin operations. + * Either a success response with relogin details or an error. + * @public + * @example + * function handleReLogin(resp: StationReLoginResponse) { ... } + * @ignore + */ export type StationReLoginResponse = Agent.ReloginSuccess | Error; + +/** + * Response type for agent state change operations. + * Either a success response with state change details or an error. + * @public + * @example + * function handleStateChange(resp: SetStateResponse) { ... } + * @ignore + */ export type SetStateResponse = Agent.StateChangeSuccess | Error; + +/** + * Response type for buddy agents query operations. + * Either a success response with list of buddy agents or an error. + * @public + * @example + * function handleBuddyAgents(resp: BuddyAgentsResponse) { ... } + * @ignore + */ export type BuddyAgentsResponse = Agent.BuddyAgentsSuccess | Error; + +/** + * Response type for device type update operations. + * Either a success response with update confirmation or an error. + * @public + * @example + * function handleUpdateDeviceType(resp: UpdateDeviceTypeResponse) { ... } + * @ignore + */ export type UpdateDeviceTypeResponse = Agent.DeviceTypeUpdateSuccess | Error; diff --git a/packages/@webex/plugin-cc/src/webex-config.ts b/packages/@webex/plugin-cc/src/webex-config.ts index b576320f192..a3586a7fe9f 100644 --- a/packages/@webex/plugin-cc/src/webex-config.ts +++ b/packages/@webex/plugin-cc/src/webex-config.ts @@ -1,13 +1,52 @@ import {MemoryStoreAdapter} from '@webex/webex-core'; +/** + * Default Webex SDK configuration for Contact Center integration. + * + * @public + * @example + * import webexConfig from './webex-config'; + * const hydraUrl = webexConfig.hydra; + */ export default { + /** + * URL for the Hydra API service. + * @type {string} + * @public + * @default process.env.HYDRA_SERVICE_URL || 'https://api.ciscospark.com/v1' + */ hydra: process.env.HYDRA_SERVICE_URL || 'https://api.ciscospark.com/v1', + /** + * Alias for the Hydra API service URL. + * @type {string} + * @public + * @default process.env.HYDRA_SERVICE_URL || 'https://api.ciscospark.com/v1' + */ hydraServiceUrl: process.env.HYDRA_SERVICE_URL || 'https://api.ciscospark.com/v1', + /** + * Credentials configuration (empty by default). + * @type {object} + * @public + */ credentials: {}, + /** + * Device configuration options. + * @type {object} + * @public + * @property {boolean} validateDomains - Whether to validate device domains. + * @property {boolean} ephemeral - Whether the device is ephemeral. + */ device: { validateDomains: true, ephemeral: true, }, + /** + * Storage configuration for the SDK. + * @type {object} + * @public + * @property {typeof MemoryStoreAdapter} boundedAdapter - Adapter for bounded storage. + * @property {typeof MemoryStoreAdapter} unboundedAdapter - Adapter for unbounded storage. + */ storage: { boundedAdapter: MemoryStoreAdapter, unboundedAdapter: MemoryStoreAdapter, diff --git a/packages/@webex/plugin-cc/src/webex.js b/packages/@webex/plugin-cc/src/webex.js index fa1c7344359..821fdb1e51e 100644 --- a/packages/@webex/plugin-cc/src/webex.js +++ b/packages/@webex/plugin-cc/src/webex.js @@ -10,15 +10,83 @@ import './index'; import config from './webex-config'; +/** + * Ensures global Buffer is defined, which is required for SDK functionality in some environments. + * @ignore + */ if (!global.Buffer) { global.Buffer = Buffer; } +/** + * Webex SDK class extended from the core SDK. + * Includes custom configuration and plugin registration for CC (Contact Center) use cases. + * @ignore + */ const Webex = WebexCore.extend({ webex: true, version: PACKAGE_VERSION, }); +/** + * Initializes a new Webex instance with merged configuration. + * + * @param {Object} [attrs={}] - Initialization attributes. + * @param {Object} [attrs.config] - Optional custom config to override defaults. + * @param {Object} [attrs.config.logger] - Logging configuration. + * @param {string} [attrs.config.logger.level='info'] - Logging level (e.g., 'debug', 'info'). + * @param {string} [attrs.config.logger.bufferLogLevel='log'] - Log buffering level for log uploads. + * @param {Object} [attrs.config.cc] - Contact Center (CC) specific configurations. + * @param {boolean} [attrs.config.cc.allowMultiLogin=false] - Whether to allow multiple logins. + * @param {boolean} [attrs.config.cc.allowAutomatedRelogin=true] - Whether to allow automated re-login. + * @param {string} [attrs.config.cc.clientType='WebexCCSDK'] - Type of the client. + * @param {boolean} [attrs.config.cc.isKeepAliveEnabled=false] - Whether to enable keep-alive functionality. + * @param {boolean} [attrs.config.cc.force=true] - Whether to force specific CC configurations. + * @param {Object} [attrs.config.cc.metrics] - Metrics configuration for CC. + * @param {string} [attrs.config.cc.metrics.clientName='WEBEX_JS_SDK'] - Metrics client name. + * @param {string} [attrs.config.cc.metrics.clientType='WebexCCSDK'] - Metrics client type. + * @returns {Webex} A new Webex instance. + * + * @see {@link https://developer.webex.com/meeting/docs/sdks/webex-meetings-sdk-web-quickstart#webex-object-attribute-reference} - Webex Object Attribute Reference for SDK Configuration. + * + * This configuration merges the default `webex-config` with any custom configuration provided as `attrs.config`. + * The merged configuration governs various SDK behaviors, such as authorization, logging, and CC-specific settings. + * + * @example Basic Usage + * import Webex from '@webex/plugin-cc'; + * + * // Initialize Webex SDK with default configuration + * const webex = Webex.init(); + * + * @example Custom Configuration + * import Webex from '@webex/plugin-cc'; + * + * const customConfig = { + * logger: { + * level: 'debug', // Enable debug logging + * bufferLogLevel: 'log', // Used for upload logs + * }, + * credentials: { + * client_id: 'your-client-id', // Replace with your Webex application's client ID + * client_secret: 'your-client-secret', // Replace with your Webex application's client secret + * redirect_uri: 'https://your-redirect-uri', // Replace with your app's redirect URI + * }, + * cc: { + * allowMultiLogin: false, // Disallow multiple logins + * allowAutomatedRelogin: true, // Enable automated re-login + * clientType: 'WebexCCSDK', // Specify the Contact Center client type + * isKeepAliveEnabled: false, // Disable keep-alive functionality + * force: true, // Force CC-specific configurations + * metrics: { + * clientName: 'WEBEX_JS_SDK', // Metrics client name + * clientType: 'WebexCCSDK', // Metrics client type + * }, + * }, + * }; + * + * // Initialize Webex SDK with custom configuration + * const webex = Webex.init({ config: customConfig }); + */ Webex.init = function init(attrs = {}) { attrs.config = merge({}, config, attrs.config); diff --git a/packages/@webex/plugin-cc/test/unit/spec/cc.ts b/packages/@webex/plugin-cc/test/unit/spec/cc.ts index d177d80333c..a346bc54b55 100644 --- a/packages/@webex/plugin-cc/test/unit/spec/cc.ts +++ b/packages/@webex/plugin-cc/test/unit/spec/cc.ts @@ -30,7 +30,7 @@ import {Profile} from '../../../src/services/config/types'; import TaskManager from '../../../src/services/task/TaskManager'; import {AgentContact, TASK_EVENTS} from '../../../src/services/task/types'; import MetricsManager from '../../../src/metrics/MetricsManager'; -import { METRIC_EVENT_NAMES } from '../../../src/metrics/constants'; +import {METRIC_EVENT_NAMES} from '../../../src/metrics/constants'; import Mercury from '@webex/internal-plugin-mercury'; import WebexRequest from '../../../src/services/core/WebexRequest'; @@ -76,7 +76,7 @@ describe('webex.cc', () => { getOrgId: jest.fn(() => 'mockOrgId'), }, config: config, - once: jest.fn((event, callback) => callback()), + once: jest.fn((event, callback) => callback()), }) as unknown as WebexSDK; mockWebSocketManager = { @@ -274,6 +274,19 @@ describe('webex.cc', () => { const result = await webex.cc.register(); + // Verify logging calls + expect(LoggerProxy.info).toHaveBeenCalledWith('Starting CC SDK registration', { + module: CC_FILE, + method: 'register', + }); + expect(LoggerProxy.log).toHaveBeenCalledWith( + `CC SDK registration completed successfully with agentId: ${result.agentId}`, + { + module: CC_FILE, + method: 'register', + } + ); + expect(mercuryConnect).toHaveBeenCalled(); expect(connectWebsocketSpy).toHaveBeenCalled(); expect(setupEventListenersSpy).toHaveBeenCalled(); @@ -300,7 +313,13 @@ describe('webex.cc', () => { expect(configSpy).toHaveBeenCalled(); expect(LoggerProxy.log).toHaveBeenCalledWith('Agent config is fetched successfully', { module: CC_FILE, - method: 'mockConstructor', + method: 'connectWebsocket', + }); + expect(mockTaskManager.setConfigFlags).toHaveBeenCalledWith({ + isEndCallEnabled: mockAgentProfile.isEndCallEnabled, + isEndConsultEnabled: mockAgentProfile.isEndConsultEnabled, + webRtcEnabled: mockAgentProfile.webRtcEnabled, + autoWrapup: mockAgentProfile.wrapUpData.wrapUpProps.autoWrapup ?? false, }); expect(mockTaskManager.setConfigFlags).toHaveBeenCalledWith({ isEndCallEnabled: mockAgentProfile.isEndCallEnabled, @@ -312,7 +331,7 @@ describe('webex.cc', () => { expect(result).toEqual(mockAgentProfile); expect(mockMetricsManager.timeEvent).toHaveBeenCalledWith([ METRIC_EVENT_NAMES.WEBSOCKET_REGISTER_SUCCESS, - METRIC_EVENT_NAMES.WEBSOCKET_REGISTER_FAILED + METRIC_EVENT_NAMES.WEBSOCKET_REGISTER_FAILED, ]); }); @@ -349,13 +368,13 @@ describe('webex.cc', () => { expect(configSpy).toHaveBeenCalled(); expect(LoggerProxy.log).toHaveBeenCalledWith('Agent config is fetched successfully', { module: CC_FILE, - method: 'mockConstructor', + method: 'connectWebsocket', }); expect(reloadSpy).not.toHaveBeenCalled(); expect(result).toEqual(mockAgentProfile); expect(mockMetricsManager.timeEvent).toHaveBeenCalledWith([ METRIC_EVENT_NAMES.WEBSOCKET_REGISTER_SUCCESS, - METRIC_EVENT_NAMES.WEBSOCKET_REGISTER_FAILED + METRIC_EVENT_NAMES.WEBSOCKET_REGISTER_FAILED, ]); }); @@ -366,10 +385,23 @@ describe('webex.cc', () => { await expect(webex.cc.register()).rejects.toThrow('Error while performing register'); + expect(LoggerProxy.info).toHaveBeenCalledWith('Starting CC SDK registration', { + module: CC_FILE, + method: 'register', + }); expect(LoggerProxy.error).toHaveBeenCalledWith(`Error during register: ${mockError}`, { module: CC_FILE, method: 'register', }); + + // Verify metrics tracking + expect(mockMetricsManager.trackEvent).toHaveBeenCalledWith( + METRIC_EVENT_NAMES.WEBSOCKET_REGISTER_FAILED, + { + orgId: undefined, + }, + ['operational'] + ); }); it('should log error if mercury connect fails but cc.register() should not fail', async () => { @@ -395,10 +427,13 @@ describe('webex.cc', () => { const result = await webex.cc.register(); - expect(LoggerProxy.error).toHaveBeenCalledWith(`Error occurred during mercury.connect() ${mockError}`, { - module: CC_FILE, - method: 'mockConstructor', - }); + expect(LoggerProxy.error).toHaveBeenCalledWith( + `Error occurred during mercury.connect() ${mockError}`, + { + module: CC_FILE, + method: 'connectWebsocket', + } + ); expect(connectWebsocketSpy).toHaveBeenCalled(); expect(setupEventListenersSpy).toHaveBeenCalled(); expect(mockWebSocketManager.initWebSocket).toHaveBeenCalledWith({ @@ -410,7 +445,6 @@ describe('webex.cc', () => { }, }); - expect(mockTaskManager.on).toHaveBeenCalledWith( TASK_EVENTS.TASK_INCOMING, expect.any(Function) @@ -424,7 +458,7 @@ describe('webex.cc', () => { expect(configSpy).toHaveBeenCalled(); expect(LoggerProxy.log).toHaveBeenCalledWith('Agent config is fetched successfully', { module: CC_FILE, - method: 'mockConstructor', + method: 'connectWebsocket', }); expect(reloadSpy).toHaveBeenCalled(); expect(result).toEqual(mockAgentProfile); @@ -480,7 +514,7 @@ describe('webex.cc', () => { webex.cc.agentConfig = { agentId: 'agentId', webRtcEnabled: true, - loginVoiceOptions: ['BROWSER', 'EXTENSION', 'AGENT_DN'] + loginVoiceOptions: ['BROWSER', 'EXTENSION', 'AGENT_DN'], }; const registerWebCallingLineSpy = jest.spyOn( @@ -498,11 +532,15 @@ describe('webex.cc', () => { trackingId: '1234', eventType: 'DESKTOP_MESSAGE', channelsMap: { - chat: ["25d8ggg7-4821-7de7-b626-36437adec509", "14e7fff7-7de7-4821-a919-36437adec509"], - email: ["14e7fff7-7de7-4821-a919-36437adec509", "14e7fff7-7de7-4821-a919-36437adec509", "14e7fff7-7de7-4821-a919-36437adec509"], + chat: ['25d8ggg7-4821-7de7-b626-36437adec509', '14e7fff7-7de7-4821-a919-36437adec509'], + email: [ + '14e7fff7-7de7-4821-a919-36437adec509', + '14e7fff7-7de7-4821-a919-36437adec509', + '14e7fff7-7de7-4821-a919-36437adec509', + ], social: [], - telephony:["14e7fff7-7de7-4821-a919-36437adec509"], - } + telephony: ['14e7fff7-7de7-4821-a919-36437adec509'], + }, }, trackingId: 'notifs_52628', orgId: 'orgId', @@ -522,10 +560,10 @@ describe('webex.cc', () => { chat: 2, email: 3, social: 0, - telephony: 1 + telephony: 1, }, - notifsTrackingId: 'notifs_52628' - } + notifsTrackingId: 'notifs_52628', + }; const stationLoginMock = jest .spyOn(webex.cc.services.agent, 'stationLogin') @@ -549,7 +587,10 @@ describe('webex.cc', () => { }, }); - expect(mockMetricsManager.timeEvent).toBeCalledWith([METRIC_EVENT_NAMES.STATION_LOGIN_SUCCESS, METRIC_EVENT_NAMES.STATION_LOGIN_FAILED]); + expect(mockMetricsManager.timeEvent).toBeCalledWith([ + METRIC_EVENT_NAMES.STATION_LOGIN_SUCCESS, + METRIC_EVENT_NAMES.STATION_LOGIN_FAILED, + ]); expect(result).toEqual(responseMock); const onSpy = jest.spyOn(mockTaskManager, 'on'); @@ -580,12 +621,18 @@ describe('webex.cc', () => { // Simulate receiving a message event messageCallback(JSON.stringify(agentStateChangeEventData)); - expect(ccEmitSpy).toHaveBeenCalledWith(AGENT_EVENTS.AGENT_STATE_CHANGE, agentStateChangeEventData.data); + expect(ccEmitSpy).toHaveBeenCalledWith( + AGENT_EVENTS.AGENT_STATE_CHANGE, + agentStateChangeEventData.data + ); // Simulate receiving a message event messageCallback(JSON.stringify(agentMultiLoginEventData)); - expect(ccEmitSpy).toHaveBeenCalledWith(AGENT_EVENTS.AGENT_MULTI_LOGIN, agentMultiLoginEventData.data); + expect(ccEmitSpy).toHaveBeenCalledWith( + AGENT_EVENTS.AGENT_MULTI_LOGIN, + agentMultiLoginEventData.data + ); }); it('should not attempt mobius registration for LoginOption.BROWSER if webrtc is disabled', async () => { @@ -596,7 +643,7 @@ describe('webex.cc', () => { webex.cc.agentConfig = { agentId: 'agentId', - webRtcEnabled: false + webRtcEnabled: false, }; const mockData = { @@ -609,17 +656,17 @@ describe('webex.cc', () => { trackingId: '1234', eventType: 'DESKTOP_MESSAGE', channelsMap: { - chat: ["25d8ggg7-4821-7de7-b626-36437adec509", "14e7fff7-7de7-4821-a919-36437adec509"], + chat: ['25d8ggg7-4821-7de7-b626-36437adec509', '14e7fff7-7de7-4821-a919-36437adec509'], email: [], social: [], - telephony:["14e7fff7-7de7-4821-a919-36437adec509"], - } + telephony: ['14e7fff7-7de7-4821-a919-36437adec509'], + }, }, trackingId: '1234', orgId: 'orgId', type: 'StationLoginSuccess', eventType: 'STATION_LOGIN', - } + }; const registerWebCallingLineSpy = jest.spyOn( webex.cc.webCallingService, @@ -627,7 +674,8 @@ describe('webex.cc', () => { ); const stationLoginSpy = jest - .spyOn(webex.cc.services.agent, 'stationLogin').mockResolvedValue(mockData as unknown as StationLoginSuccess); + .spyOn(webex.cc.services.agent, 'stationLogin') + .mockResolvedValue(mockData as unknown as StationLoginSuccess); await webex.cc.stationLogin(options); @@ -650,13 +698,13 @@ describe('webex.cc', () => { it('should login successfully with other LoginOption', async () => { webex.cc.agentConfig = { - webRtcEnabled: true + webRtcEnabled: true, }; const options = { teamId: 'teamId', loginOption: LoginOption.AGENT_DN, - dialNumber: '1234567890', + dialNumber: '12345678901', }; const mockData = { @@ -669,11 +717,15 @@ describe('webex.cc', () => { trackingId: '1234', eventType: 'DESKTOP_MESSAGE', channelsMap: { - chat: ["25d8ggg7-4821-7de7-b626-36437adec509", "14e7fff7-7de7-4821-a919-36437adec509"], - email: ["14e7fff7-7de7-4821-a919-36437adec509", "14e7fff7-7de7-4821-a919-36437adec509", "14e7fff7-7de7-4821-a919-36437adec509"], + chat: ['25d8ggg7-4821-7de7-b626-36437adec509', '14e7fff7-7de7-4821-a919-36437adec509'], + email: [ + '14e7fff7-7de7-4821-a919-36437adec509', + '14e7fff7-7de7-4821-a919-36437adec509', + '14e7fff7-7de7-4821-a919-36437adec509', + ], social: [], - telephony:["14e7fff7-7de7-4821-a919-36437adec509"], - } + telephony: ['14e7fff7-7de7-4821-a919-36437adec509'], + }, }, trackingId: 'notifs_52628', orgId: 'orgId', @@ -693,10 +745,10 @@ describe('webex.cc', () => { chat: 2, email: 3, social: 0, - telephony: 1 + telephony: 1, }, - notifsTrackingId: 'notifs_52628' - } + notifsTrackingId: 'notifs_52628', + }; const stationLoginMock = jest .spyOn(webex.cc.services.agent, 'stationLogin') @@ -704,13 +756,27 @@ describe('webex.cc', () => { const result = await webex.cc.stationLogin(options); + // Verify logging calls + expect(LoggerProxy.info).toHaveBeenCalledWith('Starting agent station login', { + module: CC_FILE, + method: 'stationLogin', + }); + expect(LoggerProxy.log).toHaveBeenCalledWith( + `Agent station login completed successfully agentId: ${mockData.data.agentId} loginOption: ${mockData.data.loginOption} teamId: ${mockData.data.teamId}`, + { + module: CC_FILE, + method: 'stationLogin', + trackingId: mockData.trackingId, + } + ); + expect(stationLoginMock).toHaveBeenCalledWith({ data: { - dialNumber: '1234567890', + dialNumber: '12345678901', teamId: 'teamId', deviceType: LoginOption.AGENT_DN, isExtension: false, - deviceId: '1234567890', + deviceId: '12345678901', roles: [AGENT], teamName: '', siteId: '', @@ -723,7 +789,7 @@ describe('webex.cc', () => { it('should handle error during stationLogin', async () => { webex.cc.agentConfig = { - webRtcEnabled: true + webRtcEnabled: true, }; const options = { @@ -740,13 +806,18 @@ describe('webex.cc', () => { }, }, }; + jest.spyOn(webex.cc.services.agent, 'stationLogin').mockRejectedValue(error); await expect(webex.cc.stationLogin(options)).rejects.toThrow(error.details.data.reason); + expect(LoggerProxy.info).toHaveBeenCalledWith('Starting agent station login', { + module: CC_FILE, + method: 'stationLogin', + }); expect(LoggerProxy.error).toHaveBeenCalledWith( - `stationLogin failed with trackingId: ${error.details.trackingId}`, - {module: CC_FILE, method: 'stationLogin'} + `stationLogin failed with reason: ${error.details.data.reason}`, + {module: CC_FILE, method: 'stationLogin', trackingId: error.details.trackingId} ); }); }); @@ -762,6 +833,16 @@ describe('webex.cc', () => { const result = await webex.cc.stationLogout(data); + // Verify logging calls + expect(LoggerProxy.info).toHaveBeenCalledWith('Starting agent station logout', { + module: CC_FILE, + method: 'stationLogout', + }); + expect(LoggerProxy.log).toHaveBeenCalledWith('Agent station logout completed successfully', { + module: CC_FILE, + method: 'stationLogout', + }); + expect(stationLogoutMock).toHaveBeenCalledWith({data: data}); // TODO: https://jira-eng-gpk2.cisco.com/jira/browse/SPARK-626777 Implement the de-register method and close the listener there // expect(mockTaskManager.unregisterIncomingCallEvent).toHaveBeenCalledWith(); @@ -777,7 +858,7 @@ describe('webex.cc', () => { expect(result).toEqual(response); expect(mockMetricsManager.timeEvent).toHaveBeenCalledWith([ METRIC_EVENT_NAMES.STATION_LOGOUT_SUCCESS, - METRIC_EVENT_NAMES.STATION_LOGOUT_FAILED + METRIC_EVENT_NAMES.STATION_LOGOUT_FAILED, ]); }); @@ -796,59 +877,15 @@ describe('webex.cc', () => { await expect(webex.cc.stationLogout(data)).rejects.toThrow(error.details.data.reason); + expect(LoggerProxy.info).toHaveBeenCalledWith('Starting agent station logout', { + module: CC_FILE, + method: 'stationLogout', + }); expect(LoggerProxy.error).toHaveBeenCalledWith( - `stationLogout failed with trackingId: ${error.details.trackingId}`, - {module: CC_FILE, method: 'stationLogout'} - ); - }); - }); - - describe('stationRelogin', () => { - it('should relogin successfully', async () => { - const response = {}; - - const stationLoginMock = jest - .spyOn(webex.cc.services.agent, 'reload') - .mockResolvedValue({} as StationLoginSuccess); - - const result = await webex.cc.stationReLogin(); - - expect(stationLoginMock).toHaveBeenCalled(); - expect(result).toEqual(response); - expect(mockMetricsManager.timeEvent).toHaveBeenCalledWith([ - METRIC_EVENT_NAMES.STATION_RELOGIN_SUCCESS, - METRIC_EVENT_NAMES.STATION_RELOGIN_FAILED - ]); - }); - - it('should handle error during relogin', async () => { - const error = { - details: { - trackingId: '1234', - data: { - reason: 'Error while performing station relogin', - }, - }, - }; - - jest.spyOn(webex.cc.services.agent, 'reload').mockRejectedValue(error); - - await expect(webex.cc.stationReLogin()).rejects.toThrow(error.details.data.reason); - - expect(LoggerProxy.error).toHaveBeenCalledWith( - `stationReLogin failed with trackingId: ${error.details.trackingId}`, - {module: CC_FILE, method: 'stationReLogin'} + `stationLogout failed with reason: ${error.details.data.reason}`, + {module: CC_FILE, method: 'stationLogout', trackingId: error.details.trackingId} ); }); - - it('should trigger TASK_HYDRATE event with the task', () => { - const task = {id: 'task1'}; - const triggerSpy = jest.spyOn(webex.cc, 'trigger'); - - webex.cc['handleTaskHydrate'](task); - - expect(triggerSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_HYDRATE, task); - }); }); describe('setAgentStatus', () => { @@ -862,19 +899,28 @@ describe('webex.cc', () => { const setAgentStatusMock = jest .spyOn(webex.cc.services.agent, 'stateChange') - .mockResolvedValue(expectedPayload); + .mockResolvedValue({data: expectedPayload}); const result = await webex.cc.setAgentState(expectedPayload); - expect(setAgentStatusMock).toHaveBeenCalledWith({data: expectedPayload}); - expect(result).toEqual(expectedPayload); - expect(LoggerProxy.log).toHaveBeenCalledWith('SET AGENT STATUS API SUCCESS', { + // Verify logging calls + expect(LoggerProxy.info).toHaveBeenCalledWith('Setting agent state', { module: CC_FILE, method: 'setAgentState', }); + expect(LoggerProxy.log).toHaveBeenCalledWith( + `Agent state changed successfully to auxCodeId: ${expectedPayload.auxCodeId}`, + { + module: CC_FILE, + method: 'setAgentState', + } + ); + + expect(setAgentStatusMock).toHaveBeenCalledWith({data: expectedPayload}); + expect(result).toEqual({data: expectedPayload}); expect(mockMetricsManager.timeEvent).toHaveBeenCalledWith([ METRIC_EVENT_NAMES.AGENT_STATE_CHANGE_SUCCESS, - METRIC_EVENT_NAMES.AGENT_STATE_CHANGE_FAILED + METRIC_EVENT_NAMES.AGENT_STATE_CHANGE_FAILED, ]); }); @@ -888,19 +934,24 @@ describe('webex.cc', () => { const setAgentStatusMock = jest .spyOn(webex.cc.services.agent, 'stateChange') - .mockResolvedValue(expectedPayload); + .mockResolvedValue({data: expectedPayload}); const result = await webex.cc.setAgentState(expectedPayload); expect(setAgentStatusMock).toHaveBeenCalledWith({data: expectedPayload}); - expect(result).toEqual(expectedPayload); - expect(LoggerProxy.log).toHaveBeenCalledWith('SET AGENT STATUS API SUCCESS', { - module: CC_FILE, - method: 'setAgentState', - }); + expect(result).toEqual({data: expectedPayload}); + expect(LoggerProxy.log).toHaveBeenCalledWith( + `Agent state changed successfully to auxCodeId: ${expectedPayload.auxCodeId}`, + { + module: CC_FILE, + method: 'setAgentState', + } + ); + expect(setAgentStatusMock).toHaveBeenCalledWith({data: expectedPayload}); + expect(result).toEqual({data: expectedPayload}); expect(mockMetricsManager.timeEvent).toHaveBeenCalledWith([ METRIC_EVENT_NAMES.AGENT_STATE_CHANGE_SUCCESS, - METRIC_EVENT_NAMES.AGENT_STATE_CHANGE_FAILED + METRIC_EVENT_NAMES.AGENT_STATE_CHANGE_FAILED, ]); }); @@ -925,9 +976,14 @@ describe('webex.cc', () => { await expect(webex.cc.setAgentState(expectedPayload)).rejects.toThrow( error.details.data.reason ); + + expect(LoggerProxy.info).toHaveBeenCalledWith('Setting agent state', { + module: CC_FILE, + method: 'setAgentState', + }); expect(LoggerProxy.error).toHaveBeenCalledWith( - `setAgentState failed with trackingId: ${error.details.trackingId}`, - {module: CC_FILE, method: 'setAgentState'} + `setAgentState failed with reason: ${error.details.data.reason}`, + {module: CC_FILE, method: 'setAgentState', trackingId: error.details.trackingId} ); }); @@ -952,8 +1008,8 @@ describe('webex.cc', () => { error.details.data.reason ); expect(LoggerProxy.error).toHaveBeenCalledWith( - `setAgentState failed with trackingId: ${error.details.trackingId}`, - {module: CC_FILE, method: 'setAgentState'} + `setAgentState failed with reason: ${error.details.data.reason}`, + {module: CC_FILE, method: 'setAgentState', trackingId: error.details.trackingId} ); }); }); @@ -966,7 +1022,7 @@ describe('webex.cc', () => { agentProfileID: 'test-agent-profile-id', }; - const buddyAgentsResponse: BuddyAgentsResponse = { + const buddyAgentsResponse = { type: 'BuddyAgentsSuccess', orgId: '', trackingId: '1234', @@ -996,6 +1052,20 @@ describe('webex.cc', () => { const result = await webex.cc.getBuddyAgents(data); + // Verify logging calls + expect(LoggerProxy.info).toHaveBeenCalledWith('Fetching buddy agents', { + module: CC_FILE, + method: 'getBuddyAgents', + }); + expect(LoggerProxy.log).toHaveBeenCalledWith( + `Successfully retrieved ${buddyAgentsResponse.data.agentList.length} buddy agents`, + { + module: CC_FILE, + method: 'getBuddyAgents', + trackingId: buddyAgentsResponse.trackingId, + } + ); + expect(buddyAgentsSpy).toHaveBeenCalledWith({ data: {agentProfileId: 'test-agent-profile-id', ...data}, }); @@ -1003,7 +1073,7 @@ describe('webex.cc', () => { expect(result).toEqual(buddyAgentsResponse); expect(mockMetricsManager.timeEvent).toHaveBeenCalledWith([ METRIC_EVENT_NAMES.FETCH_BUDDY_AGENTS_SUCCESS, - METRIC_EVENT_NAMES.FETCH_BUDDY_AGENTS_FAILED + METRIC_EVENT_NAMES.FETCH_BUDDY_AGENTS_FAILED, ]); }); @@ -1035,9 +1105,13 @@ describe('webex.cc', () => { jest.spyOn(webex.cc.services.agent, 'buddyAgents').mockRejectedValue(error); await expect(webex.cc.getBuddyAgents(data)).rejects.toThrow(error.details.data.reason); + expect(LoggerProxy.info).toHaveBeenCalledWith('Fetching buddy agents', { + module: CC_FILE, + method: 'getBuddyAgents', + }); expect(LoggerProxy.error).toHaveBeenCalledWith( - `getBuddyAgents failed with trackingId: ${error.details.trackingId}`, - {module: CC_FILE, method: 'getBuddyAgents'} + `getBuddyAgents failed with reason: ${error.details.data.reason}`, + {module: CC_FILE, method: 'getBuddyAgents', trackingId: error.details.trackingId} ); }); }); @@ -1074,18 +1148,26 @@ describe('webex.cc', () => { 'registerWebCallingLine' ); - const setLoginOptionSpy = jest.spyOn( - webex.cc.webCallingService, - 'setLoginOption' - ); + const setLoginOptionSpy = jest.spyOn(webex.cc.webCallingService, 'setLoginOption'); const incomingTaskListenerSpy = jest.spyOn(webex.cc, 'incomingTaskListener'); const webSocketManagerOnSpy = jest.spyOn(webex.cc.services.webSocketManager, 'on'); await webex.cc['silentRelogin'](); + expect(LoggerProxy.info).toHaveBeenCalledWith('Starting silent relogin process', { + module: CC_FILE, + method: 'silentRelogin', + }); expect(LoggerProxy.info).toHaveBeenCalledWith( 'event=requestAutoStateChange | Requesting state change to available on socket reconnect', {module: CC_FILE, method: 'silentRelogin'} ); + expect(LoggerProxy.log).toHaveBeenCalledWith( + `Silent relogin process completed successfully with login Option: ${mockReLoginResponse.data.deviceType} teamId: ${mockReLoginResponse.data.teamId}`, + { + module: CC_FILE, + method: 'silentRelogin', + } + ); expect(setAgentStateSpy).toHaveBeenCalledWith({ state: 'Available', auxCodeId: '0', // even if get auxcodeId from relogin response, it should be 0 for available state @@ -1120,17 +1202,33 @@ describe('webex.cc', () => { jest.spyOn(webex.cc.services.agent, 'reload').mockRejectedValue(error); await webex.cc['silentRelogin'](); + expect(LoggerProxy.info).toHaveBeenCalledWith('Starting silent relogin process', { + module: CC_FILE, + method: 'silentRelogin', + }); expect(LoggerProxy.log).toHaveBeenCalledWith( - 'Agent not found during re-login, handling silently', + 'Agent not found during relogin, handling silently', {module: CC_FILE, method: 'silentRelogin'} ); }); it('should handle errors during silent relogin', async () => { - const error = new Error('Error while performing silentReLogin'); + const error = new Error('Error while performing silentRelogin'); jest.spyOn(webex.cc.services.agent, 'reload').mockRejectedValue(error); await expect(webex.cc['silentRelogin']()).rejects.toThrow(error); + expect(LoggerProxy.info).toHaveBeenCalledWith('Starting silent relogin process', { + module: CC_FILE, + method: 'silentRelogin', + }); + expect(LoggerProxy.error).toHaveBeenCalledWith( + `silentRelogin failed with reason: Error while performing silentRelogin`, + { + module: CC_FILE, + method: 'silentRelogin', + trackingId: undefined, + } + ); }); it('should update agentConfig with deviceType during silent relogin for EXTENSION', async () => { @@ -1142,6 +1240,7 @@ describe('webex.cc', () => { dn: '12345', lastStateChangeTimestamp: 1738575135188, lastIdleCodeChangeTimestamp: 1738575135189, + teamId: 'teamId', }, }; @@ -1160,8 +1259,20 @@ describe('webex.cc', () => { await webex.cc['silentRelogin'](); + expect(LoggerProxy.info).toHaveBeenCalledWith('Starting silent relogin process', { + module: CC_FILE, + method: 'silentRelogin', + }); + expect(LoggerProxy.log).toHaveBeenCalledWith( + `Silent relogin process completed successfully with login Option: ${mockReLoginResponse.data.deviceType} teamId: ${mockReLoginResponse.data.teamId}`, + { + module: CC_FILE, + method: 'silentRelogin', + } + ); + expect(webex.cc.agentConfig.deviceType).toBe(LoginOption.EXTENSION); - expect(webex.cc.agentConfig.defaultDn).toBe('12345'); + expect(webex.cc.agentConfig.dn).toBe('12345'); expect(webex.cc.agentConfig.lastStateAuxCodeId).toBe('auxCodeId'); expect(webex.cc.agentConfig.lastStateChangeTimestamp).toStrictEqual(1738575135188); expect(webex.cc.agentConfig.lastIdleCodeChangeTimestamp).toStrictEqual(1738575135189); @@ -1191,7 +1302,7 @@ describe('webex.cc', () => { await webex.cc['silentRelogin'](); expect(webex.cc.agentConfig.deviceType).toBe(LoginOption.AGENT_DN); - expect(webex.cc.agentConfig.defaultDn).toBe('67890'); + expect(webex.cc.agentConfig.dn).toBe('67890'); }); }); @@ -1238,8 +1349,17 @@ describe('webex.cc', () => { const result = await webex.cc.startOutdial(destination); - expect(startOutdialMock).toHaveBeenCalledWith({data: newPayload}); + // Verify logging calls + expect(LoggerProxy.info).toHaveBeenCalledWith('Starting outbound dial', { + module: CC_FILE, + method: 'startOutdial', + }); + expect(LoggerProxy.log).toHaveBeenCalledWith('Outbound dial completed successfully', { + module: CC_FILE, + method: 'startOutdial', + }); + expect(startOutdialMock).toHaveBeenCalledWith({data: newPayload}); expect(result).toEqual(mockResponse); }); @@ -1267,9 +1387,13 @@ describe('webex.cc', () => { error.details.data.reason ); + expect(LoggerProxy.info).toHaveBeenCalledWith('Starting outbound dial', { + module: CC_FILE, + method: 'startOutdial', + }); expect(LoggerProxy.error).toHaveBeenCalledWith( - `startOutdial failed with trackingId: ${error.details.trackingId}`, - {module: CC_FILE, method: 'startOutdial'} + `startOutdial failed with reason: ${error.details.data.reason}`, + {module: CC_FILE, method: `startOutdial`, trackingId: error.details.trackingId} ); expect(getErrorDetailsSpy).toHaveBeenCalledWith(error, 'startOutdial', CC_FILE); }); @@ -1292,6 +1416,19 @@ describe('webex.cc', () => { const result = await webex.cc.getQueues(); + // Verify logging calls + expect(LoggerProxy.info).toHaveBeenCalledWith('Fetching queues', { + module: CC_FILE, + method: 'getQueues', + }); + expect(LoggerProxy.log).toHaveBeenCalledWith( + `Successfully retrieved ${result.length} queues`, + { + module: CC_FILE, + method: 'getQueues', + } + ); + expect(webex.cc.services.config.getQueues).toHaveBeenCalledWith( 'mockOrgId', 0, @@ -1302,7 +1439,7 @@ describe('webex.cc', () => { expect(result).toEqual(mockQueuesResponse); }); - it('shoule throw an error if orgId is not present', async () => { + it('should throw an error if orgId is not present', async () => { jest.spyOn(webex.credentials, 'getOrgId').mockResolvedValue(undefined); webex.cc.services.config.getQueues = jest.fn(); @@ -1310,6 +1447,10 @@ describe('webex.cc', () => { await webex.cc.getQueues(); } catch (error) { expect(error).toEqual(new Error('Org ID not found.')); + expect(LoggerProxy.info).toHaveBeenCalledWith('Fetching queues', { + module: CC_FILE, + method: 'getQueues', + }); expect(LoggerProxy.error).toHaveBeenCalledWith('Org ID not found.', { module: CC_FILE, method: 'getQueues', @@ -1318,13 +1459,17 @@ describe('webex.cc', () => { } }); - it('shoule throw an error if config getQueues throws an error', async () => { + it('should throw an error if config getQueues throws an error', async () => { webex.cc.services.config.getQueues = jest.fn().mockRejectedValue(new Error('Test error.')); try { await webex.cc.getQueues(); } catch (error) { expect(error).toEqual(new Error('Test error.')); + expect(LoggerProxy.info).toHaveBeenCalledWith('Fetching queues', { + module: CC_FILE, + method: 'getQueues', + }); expect(webex.cc.services.config.getQueues).toHaveBeenCalledWith( 'mockOrgId', 0, @@ -1367,7 +1512,7 @@ describe('webex.cc', () => { let mockWebSocketManager; let mercuryDisconnectSpy; let deviceUnregisterSpy; - + beforeEach(() => { webex.cc.agentConfig = { agentId: 'agentId', @@ -1383,7 +1528,7 @@ describe('webex.cc', () => { }; webex.cc.services.webSocketManager = mockWebSocketManager; - + webex.internal = webex.internal || {}; webex.internal.mercury = { connected: true, @@ -1393,7 +1538,7 @@ describe('webex.cc', () => { webex.internal.device = { unregister: jest.fn().mockResolvedValue(), }; - + mercuryDisconnectSpy = jest.spyOn(webex.internal.mercury, 'disconnect'); deviceUnregisterSpy = jest.spyOn(webex.internal.device, 'unregister'); }); @@ -1401,10 +1546,19 @@ describe('webex.cc', () => { it('should unregister successfully and clean up all resources when webrtc is enabled', async () => { await webex.cc.deregister(); - expect(mockTaskManager.off).toHaveBeenCalledWith(TASK_EVENTS.TASK_INCOMING, expect.any(Function)); - expect(mockTaskManager.off).toHaveBeenCalledWith(TASK_EVENTS.TASK_HYDRATE, expect.any(Function)); + expect(mockTaskManager.off).toHaveBeenCalledWith( + TASK_EVENTS.TASK_INCOMING, + expect.any(Function) + ); + expect(mockTaskManager.off).toHaveBeenCalledWith( + TASK_EVENTS.TASK_HYDRATE, + expect.any(Function) + ); expect(mockWebSocketManager.off).toHaveBeenCalledWith('message', expect.any(Function)); - expect(webex.cc.services.connectionService.off).toHaveBeenCalledWith('connectionLost', expect.any(Function)); + expect(webex.cc.services.connectionService.off).toHaveBeenCalledWith( + 'connectionLost', + expect.any(Function) + ); expect(mockWebSocketManager.close).toHaveBeenCalledWith(false, 'Unregistering the SDK'); expect(webex.cc.agentConfig).toBeNull(); @@ -1413,14 +1567,14 @@ describe('webex.cc', () => { expect(webex.internal.mercury.off).toHaveBeenCalledWith('offline'); expect(mercuryDisconnectSpy).toHaveBeenCalled(); expect(deviceUnregisterSpy).toHaveBeenCalled(); - + expect(mockMetricsManager.timeEvent).toHaveBeenCalledWith([ METRIC_EVENT_NAMES.WEBSOCKET_DEREGISTER_SUCCESS, - METRIC_EVENT_NAMES.WEBSOCKET_DEREGISTER_FAIL + METRIC_EVENT_NAMES.WEBSOCKET_DEREGISTER_FAIL, ]); expect(mockMetricsManager.trackEvent).toHaveBeenCalledWith( - METRIC_EVENT_NAMES.WEBSOCKET_DEREGISTER_SUCCESS, - {}, + METRIC_EVENT_NAMES.WEBSOCKET_DEREGISTER_SUCCESS, + {}, ['operational'] ); @@ -1448,12 +1602,10 @@ describe('webex.cc', () => { const [, hydrateCallback] = hydrateCalls[0]; expect(hydrateCallback).toBe(webex.cc['handleTaskHydrate']); - const messageCalls = mockWebSocketManager.off.mock.calls.filter( - ([evt]) => evt === 'message' - ); + const messageCalls = mockWebSocketManager.off.mock.calls.filter(([evt]) => evt === 'message'); expect(messageCalls).toHaveLength(1); const [, messageCallback] = messageCalls[0]; - expect(messageCallback).toBe(webex.cc['handleWebSocketMessage']); + expect(messageCallback).toBe(webex.cc['handleWebsocketMessage']); const connectionCalls = webex.cc.services.connectionService.off.mock.calls.filter( ([evt]) => evt === 'connectionLost' @@ -1466,12 +1618,21 @@ describe('webex.cc', () => { it('should skip webCallingService and internal cleanup when webrtc is disabled', async () => { webex.cc.agentConfig.webRtcEnabled = false; await webex.cc.deregister(); - - expect(mockTaskManager.off).toHaveBeenCalledWith(TASK_EVENTS.TASK_INCOMING, expect.any(Function)); - expect(mockTaskManager.off).toHaveBeenCalledWith(TASK_EVENTS.TASK_HYDRATE, expect.any(Function)); + + expect(mockTaskManager.off).toHaveBeenCalledWith( + TASK_EVENTS.TASK_INCOMING, + expect.any(Function) + ); + expect(mockTaskManager.off).toHaveBeenCalledWith( + TASK_EVENTS.TASK_HYDRATE, + expect.any(Function) + ); expect(mockWebSocketManager.off).toHaveBeenCalledWith('message', expect.any(Function)); - expect(webex.cc.services.connectionService.off).toHaveBeenCalledWith('connectionLost', expect.any(Function)); - + expect(webex.cc.services.connectionService.off).toHaveBeenCalledWith( + 'connectionLost', + expect.any(Function) + ); + expect(webex.internal.mercury.off).not.toHaveBeenCalled(); expect(mercuryDisconnectSpy).not.toHaveBeenCalled(); expect(deviceUnregisterSpy).not.toHaveBeenCalled(); @@ -1501,25 +1662,31 @@ describe('webex.cc', () => { await expect(webex.cc.deregister()).rejects.toThrow('Failed to deregister device'); - expect(mockTaskManager.off).toHaveBeenCalledWith(TASK_EVENTS.TASK_INCOMING, expect.any(Function)); - expect(mockTaskManager.off).toHaveBeenCalledWith(TASK_EVENTS.TASK_HYDRATE, expect.any(Function)); + expect(mockTaskManager.off).toHaveBeenCalledWith( + TASK_EVENTS.TASK_INCOMING, + expect.any(Function) + ); + expect(mockTaskManager.off).toHaveBeenCalledWith( + TASK_EVENTS.TASK_HYDRATE, + expect.any(Function) + ); expect(LoggerProxy.error).toHaveBeenCalledWith(`Error during deregister: ${mockError}`, { module: CC_FILE, method: 'deregister', }); - + expect(mockMetricsManager.trackEvent).toHaveBeenCalledWith( - METRIC_EVENT_NAMES.WEBSOCKET_DEREGISTER_FAIL, + METRIC_EVENT_NAMES.WEBSOCKET_DEREGISTER_FAIL, { error: 'Failed to deregister device', - }, + }, ['operational'] ); }); }); - describe('handleWebSocketMessage events', () => { + describe('handleWebsocketMessage events', () => { let messageCallback; let emitSpy; @@ -1529,7 +1696,7 @@ describe('webex.cc', () => { }); it('should emit AGENT_STATION_LOGIN_SUCCESS on CC_EVENTS.AGENT_STATION_LOGIN_SUCCESS with mapped payload', () => { - const channelsMap = {chat: ['c1','c2'], email: [], social: ['s1'], telephony: []}; + const channelsMap = {chat: ['c1', 'c2'], email: [], social: ['s1'], telephony: []}; const payload = { trackingId: 'track-123', data: { @@ -1545,35 +1712,31 @@ describe('webex.cc', () => { messageCallback(JSON.stringify(payload)); - expect(emitSpy).toHaveBeenNthCalledWith( - 2, - AGENT_EVENTS.AGENT_STATION_LOGIN_SUCCESS, - { - agentId: 'agent-id', - teamId: 'team-id', - siteId: 'site-id', - roles: ['role1', 'role2'], - mmProfile: { - chat: 2, - email: 0, - social: 1, - telephony: 0, - }, - notifsTrackingId: 'track-123', - type: CC_EVENTS.AGENT_STATION_LOGIN_SUCCESS, - } - ); + expect(emitSpy).toHaveBeenNthCalledWith(2, AGENT_EVENTS.AGENT_STATION_LOGIN_SUCCESS, { + agentId: 'agent-id', + teamId: 'team-id', + siteId: 'site-id', + roles: ['role1', 'role2'], + mmProfile: { + chat: 2, + email: 0, + social: 1, + telephony: 0, + }, + notifsTrackingId: 'track-123', + type: CC_EVENTS.AGENT_STATION_LOGIN_SUCCESS, + }); }); it('should emit AGENT_RELOGIN_SUCCESS on CC_EVENTS.AGENT_RELOGIN_SUCCESS with mapped payload', () => { - const channelsMap = {chat: ['a','b'], email: [], social: ['x'], telephony: ['y','z']}; + const channelsMap = {chat: ['a', 'b'], email: [], social: ['x'], telephony: ['y', 'z']}; const payload = { trackingId: 'trk-relogin', data: { agentId: 'agent-re', teamId: 'team-re', siteId: 'site-re', - roles: ['r1','r2'], + roles: ['r1', 'r2'], channelsMap, type: CC_EVENTS.AGENT_RELOGIN_SUCCESS, }, @@ -1582,52 +1745,84 @@ describe('webex.cc', () => { messageCallback(JSON.stringify(payload)); - expect(emitSpy).toHaveBeenNthCalledWith( - 2, - AGENT_EVENTS.AGENT_RELOGIN_SUCCESS, - { - agentId: 'agent-re', - teamId: 'team-re', - siteId: 'site-re', - roles: ['r1', 'r2'], - mmProfile: { - chat: 2, - email: 0, - social: 1, - telephony: 2, - }, - notifsTrackingId: 'trk-relogin', - type: CC_EVENTS.AGENT_RELOGIN_SUCCESS, - } - ); + expect(emitSpy).toHaveBeenNthCalledWith(2, AGENT_EVENTS.AGENT_RELOGIN_SUCCESS, { + agentId: 'agent-re', + teamId: 'team-re', + siteId: 'site-re', + roles: ['r1', 'r2'], + mmProfile: { + chat: 2, + email: 0, + social: 1, + telephony: 2, + }, + notifsTrackingId: 'trk-relogin', + type: CC_EVENTS.AGENT_RELOGIN_SUCCESS, + }); }); [ - { ccEvent: CC_EVENTS.AGENT_STATION_LOGIN_FAILED, constant: AGENT_EVENTS.AGENT_STATION_LOGIN_FAILED }, - { ccEvent: CC_EVENTS.AGENT_LOGOUT_SUCCESS, constant: AGENT_EVENTS.AGENT_LOGOUT_SUCCESS }, - { ccEvent: CC_EVENTS.AGENT_LOGOUT_FAILED, constant: AGENT_EVENTS.AGENT_LOGOUT_FAILED }, - { ccEvent: CC_EVENTS.AGENT_DN_REGISTERED, constant: AGENT_EVENTS.AGENT_DN_REGISTERED }, - { ccEvent: CC_EVENTS.AGENT_STATE_CHANGE_SUCCESS, constant: AGENT_EVENTS.AGENT_STATE_CHANGE_SUCCESS }, - { ccEvent: CC_EVENTS.AGENT_STATE_CHANGE_FAILED, constant: AGENT_EVENTS.AGENT_STATE_CHANGE_FAILED }, - ].forEach(({ ccEvent, constant }) => { + { + ccEvent: CC_EVENTS.AGENT_STATION_LOGIN_FAILED, + constant: AGENT_EVENTS.AGENT_STATION_LOGIN_FAILED, + }, + {ccEvent: CC_EVENTS.AGENT_LOGOUT_SUCCESS, constant: AGENT_EVENTS.AGENT_LOGOUT_SUCCESS}, + {ccEvent: CC_EVENTS.AGENT_LOGOUT_FAILED, constant: AGENT_EVENTS.AGENT_LOGOUT_FAILED}, + {ccEvent: CC_EVENTS.AGENT_DN_REGISTERED, constant: AGENT_EVENTS.AGENT_DN_REGISTERED}, + { + ccEvent: CC_EVENTS.AGENT_STATE_CHANGE_SUCCESS, + constant: AGENT_EVENTS.AGENT_STATE_CHANGE_SUCCESS, + }, + { + ccEvent: CC_EVENTS.AGENT_STATE_CHANGE_FAILED, + constant: AGENT_EVENTS.AGENT_STATE_CHANGE_FAILED, + }, + ].forEach(({ccEvent, constant}) => { it(`should emit ${constant} on ${ccEvent}`, () => { - const sample = { foo: 'bar', type: ccEvent }; + const sample = {foo: 'bar', type: ccEvent}; messageCallback(JSON.stringify({type: ccEvent, data: sample})); expect(emitSpy).toHaveBeenCalledWith(constant, sample); }); }); + + it('should call webCallingService.setLoginOption with correct deviceType on AGENT_STATION_LOGIN_SUCCESS', () => { + const setLoginOptionSpy = jest.spyOn(webex.cc.webCallingService, 'setLoginOption'); + const deviceType = LoginOption.EXTENSION; + const payload = { + trackingId: 'track-123', + data: { + agentId: 'agent-id', + teamId: 'team-id', + siteId: 'site-id', + roles: ['role1', 'role2'], + channelsMap: {chat: [], email: [], social: [], telephony: []}, + deviceType, + type: CC_EVENTS.AGENT_STATION_LOGIN_SUCCESS, + }, + type: CC_EVENTS.AGENT_STATION_LOGIN_SUCCESS, + }; + + messageCallback(JSON.stringify(payload)); + + expect(setLoginOptionSpy).toHaveBeenCalledWith(deviceType); + }); }); - describe('updateAgentDeviceType', () => { + describe('updateAgentProfile', () => { beforeEach(() => { webex.cc.agentConfig = { ...webex.cc.agentConfig, currentTeamId: 'teamId', + agentId: 'agent123', } as any; }); it('should logout then login and return AgentDeviceTypeUpdateSuccess type', async () => { - const data = {loginOption: LoginOption.EXTENSION, dialNumber: '98765'}; + const data = { + teamId: 'teamId', + loginOption: LoginOption.EXTENSION, + dialNumber: '98765', + }; const mockResp = { eventType: 'AgentDesktopMessage', agentId: 'agentId', @@ -1654,9 +1849,26 @@ describe('webex.cc', () => { jest.spyOn(webex.cc, 'stationLogout').mockResolvedValue({}); jest.spyOn(webex.cc, 'stationLogin').mockResolvedValue(mockResp as any); - const result = await webex.cc.updateAgentDeviceType(data); + const result = await webex.cc.updateAgentProfile(data); - expect(webex.cc.stationLogout).toHaveBeenCalledWith({logoutReason: 'User requested agent device change'}); + // Verify logging calls + expect(LoggerProxy.info).toHaveBeenCalledWith(`starting profile update`, { + module: CC_FILE, + method: 'updateAgentProfile', + trackingId: 'WX_CC_SDK_mock-tracking-uuid', + }); + expect(LoggerProxy.log).toHaveBeenCalledWith( + `profile updated successfully with ${data.loginOption} teamId: ${data.teamId}`, + { + module: CC_FILE, + method: 'updateAgentProfile', + trackingId: 'WX_CC_SDK_mock-tracking-uuid', + } + ); + + expect(webex.cc.stationLogout).toHaveBeenCalledWith({ + logoutReason: 'User requested agent device change', + }); expect(webex.cc.stationLogin).toHaveBeenCalledWith({ teamId: 'teamId', loginOption: data.loginOption, @@ -1665,62 +1877,121 @@ describe('webex.cc', () => { expect(result).toEqual(mockResp); }); + it('should use provided teamId if passed in payload', async () => { + const dataWithTeam = { + teamId: 'newTeam', + loginOption: LoginOption.EXTENSION, + dialNumber: '0000', + }; + const mockResp = { + ...({} as any), + type: 'AgentDeviceTypeUpdateSuccess', + }; + jest.spyOn(webex.cc, 'stationLogout').mockResolvedValue({}); + const loginSpy = jest.spyOn(webex.cc, 'stationLogin').mockResolvedValue(mockResp); + + const result = await webex.cc.updateAgentProfile(dataWithTeam); + + expect(loginSpy).toHaveBeenCalledWith({ + teamId: 'newTeam', + loginOption: dataWithTeam.loginOption, + dialNumber: dataWithTeam.dialNumber, + }); + expect(result).toEqual(mockResp); + }); + it('should track failure and throw when stationLogout fails', async () => { - const data = {loginOption: LoginOption.EXTENSION, dialNumber: '98765'}; + const data = { + teamId: 'teamId', + loginOption: LoginOption.EXTENSION, + dialNumber: '98765', + }; const err = new Error('logout failure'); jest.spyOn(webex.cc, 'stationLogout').mockRejectedValue(err); const metricSpy = jest.spyOn(mockMetricsManager, 'trackEvent'); const logSpy = jest.spyOn(LoggerProxy, 'error'); - await expect(webex.cc.updateAgentDeviceType(data)).rejects.toThrow(err); + await expect(webex.cc.updateAgentProfile(data)).rejects.toThrow(err); expect(metricSpy).toHaveBeenCalledWith( METRIC_EVENT_NAMES.AGENT_DEVICE_TYPE_UPDATE_FAILED, expect.objectContaining({loginType: data.loginOption}), - ['behavioral','business','operational'] - ); - expect(logSpy).toHaveBeenCalledWith( - `[WX_CC_SDK_mock-tracking-uuid] updateAgentDeviceType | error updating profile: ${err}`, - {module: CC_FILE, method: 'updateAgentDeviceType'} + ['behavioral', 'business', 'operational'] ); + expect(logSpy).toHaveBeenCalledWith(`error updating profile: ${err}`, { + module: CC_FILE, + method: 'updateAgentProfile', + trackingId: 'WX_CC_SDK_mock-tracking-uuid', + }); }); it('should track failure and throw when stationLogin fails', async () => { - const data = {loginOption: LoginOption.EXTENSION, dialNumber: '98765'}; + const data = { + teamId: 'teamId', + loginOption: LoginOption.EXTENSION, + dialNumber: '98765', + }; jest.spyOn(webex.cc, 'stationLogout').mockResolvedValue({}); const loginErr = new Error('login failure'); jest.spyOn(webex.cc, 'stationLogin').mockRejectedValue(loginErr); const metricSpy = jest.spyOn(mockMetricsManager, 'trackEvent'); const logSpy = jest.spyOn(LoggerProxy, 'error'); - await expect(webex.cc.updateAgentDeviceType(data)).rejects.toThrow(loginErr); + await expect(webex.cc.updateAgentProfile(data)).rejects.toThrow(loginErr); expect(metricSpy).toHaveBeenCalledWith( METRIC_EVENT_NAMES.AGENT_DEVICE_TYPE_UPDATE_FAILED, expect.objectContaining({loginType: data.loginOption}), - ['behavioral','business','operational'] - ); - expect(logSpy).toHaveBeenCalledWith( - `[WX_CC_SDK_mock-tracking-uuid] updateAgentDeviceType | error updating profile: ${loginErr}`, - {module: CC_FILE, method: 'updateAgentDeviceType'} + ['behavioral', 'business', 'operational'] ); + expect(logSpy).toHaveBeenCalledWith(`error updating profile: ${loginErr}`, { + module: CC_FILE, + method: 'updateAgentProfile', + trackingId: 'WX_CC_SDK_mock-tracking-uuid', + }); }); it('should throw with detailed error when loginOption equals current device type', async () => { - const data = {loginOption: LoginOption.BROWSER, dialNumber: '11111'}; - webex.cc.webCallingService.loginOption = data.loginOption; + webex.cc.webCallingService.loginOption = LoginOption.BROWSER; + const data = { + teamId: 'teamId', + loginOption: LoginOption.BROWSER, + dialNumber: '', + }; + const expectedMessage = + 'Will not proceed with device update as new Device type is same as current device type and teamId is same as current teamId'; - await expect(webex.cc.updateAgentDeviceType(data)).rejects.toMatchObject({ - message: 'Will not proceed with device update as new Device type is same as current device type', + await expect(webex.cc.updateAgentProfile(data)).rejects.toMatchObject({ + message: expectedMessage, details: expect.objectContaining({ - type: 'Identical Device Change Failure', - trackingId: 'WX_CC_SDK_mock-tracking-uuid', data: expect.objectContaining({ agentId: webex.cc.agentConfig.agentId, - reason: 'Will not proceed with device update as new Device type is same as current device type', + reason: expectedMessage, }), }), }); }); + + it('should allow update when same device type but different teamId', async () => { + webex.cc.agentConfig.currentTeamId = 'team1'; + webex.cc.webCallingService.loginOption = LoginOption.BROWSER; + + const data = { + teamId: 'team2', + loginOption: LoginOption.BROWSER, + dialNumber: '1234', + }; + jest.spyOn(webex.cc, 'stationLogout').mockResolvedValue({}); + const loginSpy = jest.spyOn(webex.cc, 'stationLogin').mockResolvedValue({ + type: 'AgentDeviceTypeUpdateSuccess', + } as any); + + await expect(webex.cc.updateAgentProfile(data)).resolves.toBeDefined(); + expect(loginSpy).toHaveBeenCalledWith({ + teamId: 'team2', + loginOption: data.loginOption, + dialNumber: data.dialNumber, + }); + }); }); }); diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/WebCallingService.ts b/packages/@webex/plugin-cc/test/unit/spec/services/WebCallingService.ts index 0c7b3fbfab6..c9286aa2214 100644 --- a/packages/@webex/plugin-cc/test/unit/spec/services/WebCallingService.ts +++ b/packages/@webex/plugin-cc/test/unit/spec/services/WebCallingService.ts @@ -71,7 +71,8 @@ describe('WebCallingService', () => { getCallId: jest.fn().mockReturnValue('call-id-123'), }; - webRTCCalling.call = mockCall; + // Set the private call property through type assertion for testing + (webRTCCalling as any).call = mockCall; }); afterEach(() => { @@ -192,7 +193,7 @@ describe('WebCallingService', () => { expect(line.register).toHaveBeenCalled(); expect(LoggerProxy.error).toHaveBeenCalledWith( `Invalid URL from u2c catalogue: invalid-url so falling back to default domain`, - {module: WEB_CALLING_SERVICE_FILE} + {module: WEB_CALLING_SERVICE_FILE, method: 'getRTMSDomain'} ); }); @@ -269,7 +270,10 @@ describe('WebCallingService', () => { it('should answer the call and log info when call exists', () => { webRTCCalling.answerCall(localAudioStream, 'task-id'); - expect(webex.logger.info).toHaveBeenCalledWith('Call answered: task-id'); + expect(LoggerProxy.info).toHaveBeenCalledWith('Call answered: task-id', { + module: WEB_CALLING_SERVICE_FILE, + method: 'answerCall', + }); expect(mockCall.answer).toHaveBeenCalledWith(localAudioStream); }); @@ -280,8 +284,12 @@ describe('WebCallingService', () => { }); expect(() => webRTCCalling.answerCall(localAudioStream, 'task-id')).toThrow(error); - expect(webex.logger.error).toHaveBeenCalledWith( - `Failed to answer call for task-id. Error: ${error}` + expect(LoggerProxy.error).toHaveBeenCalledWith( + `Failed to answer call for task-id. Error: ${error}`, + { + module: WEB_CALLING_SERVICE_FILE, + method: 'answerCall', + } ); }); @@ -289,7 +297,10 @@ describe('WebCallingService', () => { webRTCCalling.call = null; webRTCCalling.answerCall(localAudioStream, 'task-id'); - expect(webex.logger.log).toHaveBeenCalledWith('Cannot answer a non WebRtc Call: task-id'); + expect(LoggerProxy.log).toHaveBeenCalledWith('Cannot answer a non WebRtc Call: task-id', { + module: WEB_CALLING_SERVICE_FILE, + method: 'answerCall', + }); }); }); @@ -305,7 +316,10 @@ describe('WebCallingService', () => { it('should mute the call and log info when call exists', () => { webRTCCalling.muteUnmuteCall(localAudioStream); - expect(webex.logger.info).toHaveBeenCalledWith('Call mute or unmute requested!'); + expect(LoggerProxy.info).toHaveBeenCalledWith('Call mute or unmute requested!', { + module: WEB_CALLING_SERVICE_FILE, + method: 'muteUnmuteCall', + }); expect(mockCall.mute).toHaveBeenCalledWith(localAudioStream); }); @@ -313,7 +327,10 @@ describe('WebCallingService', () => { webRTCCalling.call = null; webRTCCalling.muteUnmuteCall(localAudioStream); - expect(webex.logger.log).toHaveBeenCalledWith('Cannot mute a non WebRtc Call'); + expect(LoggerProxy.log).toHaveBeenCalledWith('Cannot mute a non WebRtc Call', { + module: WEB_CALLING_SERVICE_FILE, + method: 'muteUnmuteCall', + }); }); }); @@ -321,7 +338,10 @@ describe('WebCallingService', () => { it('should end the call and log info when call exists', () => { webRTCCalling.declineCall('task-id'); - expect(webex.logger.info).toHaveBeenCalledWith('Call end requested: task-id'); + expect(LoggerProxy.info).toHaveBeenCalledWith('Call end requested: task-id', { + module: WEB_CALLING_SERVICE_FILE, + method: 'declineCall', + }); expect(mockCall.end).toHaveBeenCalled(); }); @@ -332,8 +352,12 @@ describe('WebCallingService', () => { }); expect(() => webRTCCalling.declineCall('task-id')).toThrow(error); - expect(webex.logger.error).toHaveBeenCalledWith( - `Failed to end call: task-id. Error: ${error}` + expect(LoggerProxy.error).toHaveBeenCalledWith( + `Failed to end call: task-id. Error: ${error}`, + { + module: WEB_CALLING_SERVICE_FILE, + method: 'declineCall', + } ); }); @@ -341,7 +365,10 @@ describe('WebCallingService', () => { webRTCCalling.call = null; webRTCCalling.declineCall('task-id'); - expect(webex.logger.log).toHaveBeenCalledWith('Cannot end a non WebRtc Call: task-id'); + expect(LoggerProxy.log).toHaveBeenCalledWith('Cannot end a non WebRtc Call: task-id', { + module: WEB_CALLING_SERVICE_FILE, + method: 'declineCall', + }); }); }); diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/config/index.ts b/packages/@webex/plugin-cc/test/unit/spec/services/config/index.ts index 3775ce9446b..1fe4814f4b6 100644 --- a/packages/@webex/plugin-cc/test/unit/spec/services/config/index.ts +++ b/packages/@webex/plugin-cc/test/unit/spec/services/config/index.ts @@ -66,6 +66,11 @@ describe('AgentConfigService', () => { method: 'GET', }); expect(result).toEqual(mockResponse.body); + + expect(LoggerProxy.info).toHaveBeenCalledWith('Fetching user data using CI', { + module: CONFIG_FILE_NAME, + method: 'getUserUsingCI', + }); expect(LoggerProxy.log).toHaveBeenCalledWith('getUserUsingCI api success.', { module: CONFIG_FILE_NAME, method: 'getUserUsingCI', @@ -119,6 +124,11 @@ describe('AgentConfigService', () => { method: 'GET', }); expect(result).toEqual(mockResponse.body); + + expect(LoggerProxy.info).toHaveBeenCalledWith('Fetching desktop profile', { + module: CONFIG_FILE_NAME, + method: 'getDesktopProfileById', + }); expect(LoggerProxy.log).toHaveBeenCalledWith('getDesktopProfileById api success.', { module: CONFIG_FILE_NAME, method: 'getDesktopProfileById', @@ -178,6 +188,11 @@ describe('AgentConfigService', () => { method: 'GET', }); expect(result).toEqual(mockResponse.body); + + expect(LoggerProxy.info).toHaveBeenCalledWith('Fetching list of teams', { + module: CONFIG_FILE_NAME, + method: 'getListOfTeams', + }); expect(LoggerProxy.log).toHaveBeenCalledWith('getListOfTeams api success.', { module: CONFIG_FILE_NAME, method: 'getListOfTeams', @@ -257,6 +272,11 @@ describe('AgentConfigService', () => { method: 'GET', }); expect(result).toEqual(mockResponse.body); + + expect(LoggerProxy.info).toHaveBeenCalledWith('Fetching list of aux codes', { + module: CONFIG_FILE_NAME, + method: 'getListOfAuxCodes', + }); expect(LoggerProxy.log).toHaveBeenCalledWith('getListOfAuxCodes api success.', { module: CONFIG_FILE_NAME, method: 'getListOfAuxCodes', @@ -754,7 +774,7 @@ describe('AgentConfigService', () => { const result = await agentConfigService.getAgentConfig(mockOrgId, mockAgentId); - expect(LoggerProxy.info).toHaveBeenCalledWith('Fetched user data', { + expect(LoggerProxy.info).toHaveBeenCalledWith(`Fetched user data, userId: ${mockUserConfig.ciUserId}`, { module: CONFIG_FILE_NAME, method: 'getAgentConfig', }); @@ -891,7 +911,7 @@ describe('AgentConfigService', () => { const result = await agentConfigService.getAgentConfig(mockOrgId, mockAgentId); - expect(LoggerProxy.info).toHaveBeenCalledWith('Fetched user data', { + expect(LoggerProxy.info).toHaveBeenCalledWith(`Fetched user data, userId: ${mockUserConfig.ciUserId}`, { module: CONFIG_FILE_NAME, method: 'getAgentConfig', }); @@ -966,6 +986,11 @@ describe('AgentConfigService', () => { method: 'GET', }); expect(result).toEqual(mockQueues); + + expect(LoggerProxy.info).toHaveBeenCalledWith('Fetching queue list', { + module: CONFIG_FILE_NAME, + method: 'getQueues', + }); expect(LoggerProxy.log).toHaveBeenCalledWith('getQueues API success.', { module: CONFIG_FILE_NAME, method: 'getQueues', diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/core/Utils.ts b/packages/@webex/plugin-cc/test/unit/spec/services/core/Utils.ts index 51c5cfd771b..01645bff0f6 100644 --- a/packages/@webex/plugin-cc/test/unit/spec/services/core/Utils.ts +++ b/packages/@webex/plugin-cc/test/unit/spec/services/core/Utils.ts @@ -1,7 +1,8 @@ import * as Utils from '../../../../../src/services/core/Utils'; import LoggerProxy from '../../../../../src/logger-proxy'; import WebexRequest from '../../../../../src/services/core/WebexRequest'; -import { WebexRequestPayload } from '../../../../../src/types'; +import {LoginOption, WebexRequestPayload} from '../../../../../src/types'; +import {Failure} from '../../../../../src/services/core/GlobalTypes'; // Mock dependencies jest.mock('../../../../../src/logger-proxy', () => ({ @@ -34,7 +35,7 @@ jest.mock('../../../../../src/services/core/Err', () => { this.code = code; this.data = data; } - } + }, }; }); @@ -67,8 +68,8 @@ describe('Utils', () => { reason: 'Test reason', }); expect(LoggerProxy.error).toHaveBeenCalledWith( - `${methodName} failed with trackingId: test-tracking-id`, - { module: moduleName, method: methodName } + `${methodName} failed with reason: ${error.details.data.reason}`, + {module: moduleName, method: methodName, trackingId: 'test-tracking-id'} ); }); @@ -119,8 +120,8 @@ describe('Utils', () => { Utils.getErrorDetails(error, 'someMethod', moduleName); expect(LoggerProxy.error).toHaveBeenCalledWith( - 'someMethod failed with trackingId: normal-error-tracking-id', - { module: moduleName, method: 'someMethod' } + `someMethod failed with reason: ${error.details.data.reason}`, + {module: moduleName, method: 'someMethod', trackingId: trackingId} ); expect(WebexRequest.getInstance().uploadLogs).toHaveBeenCalledWith({ correlationId: trackingId, @@ -132,7 +133,7 @@ describe('Utils', () => { expect(() => { Utils.getErrorDetails(null, methodName, moduleName); }).toThrow(TypeError); - + expect(() => { Utils.getErrorDetails(undefined, methodName, moduleName); }).toThrow(TypeError); @@ -142,7 +143,7 @@ describe('Utils', () => { const unexpectedError = { // No details property message: 'Unexpected error structure', - code: 500 + code: 500, }; const result = Utils.getErrorDetails(unexpectedError, methodName, moduleName); @@ -152,18 +153,18 @@ describe('Utils', () => { error: new Error(`Error while performing ${methodName}`), reason: `Error while performing ${methodName}`, }); - + // Should not throw when accessing properties with optional chaining expect(LoggerProxy.error).toHaveBeenCalledWith( - `${methodName} failed with trackingId: undefined`, - { module: moduleName, method: methodName } + `${methodName} failed with reason: Error while performing ${methodName}`, + {module: moduleName, method: methodName, trackingId: undefined} ); }); it('should prioritize trackingId from the correct location when present in multiple places', () => { const detailsTrackingId = 'details-level-tracking-id'; const dataTrackingId = 'data-level-tracking-id'; - + const error = { details: { data: { @@ -178,10 +179,10 @@ describe('Utils', () => { // Check if error logging uses the trackingId from the details level expect(LoggerProxy.error).toHaveBeenCalledWith( - `${methodName} failed with trackingId: ${detailsTrackingId}`, - { module: moduleName, method: methodName } + `${methodName} failed with reason: ${error.details.data.reason}`, + {module: moduleName, method: methodName, trackingId: detailsTrackingId} ); - + // Check if uploadLogs uses the trackingId from the details level expect(WebexRequest.getInstance().uploadLogs).toHaveBeenCalledWith({ correlationId: detailsTrackingId, @@ -192,8 +193,8 @@ describe('Utils', () => { describe('createErrDetailsObject', () => { it('should create error details object with correct parameters', () => { const errObj: WebexRequestPayload = { - headers: { trackingid: 'test-tracking-id' }, - body: { message: 'Error message' }, + headers: {trackingid: 'test-tracking-id'}, + body: {message: 'Error message'}, }; const result = Utils.createErrDetailsObject(errObj); @@ -201,7 +202,7 @@ describe('Utils', () => { expect(result.code).toBe('Service.reqs.generic.failure'); expect(result.data).toEqual({ trackingId: 'test-tracking-id', - msg: { message: 'Error message' }, + msg: {message: 'Error message'}, }); }); @@ -217,4 +218,62 @@ describe('Utils', () => { }); }); }); -}); \ No newline at end of file + + describe('getStationLoginErrorData', () => { + it('should return DUPLICATE_LOCATION message and fieldName for extension', () => { + const failure = {data: {reason: 'DUPLICATE_LOCATION'}} as Failure; + const result = Utils.getStationLoginErrorData(failure, LoginOption.EXTENSION); + expect(result).toEqual({ + message: 'This extension is already in use', + fieldName: LoginOption.EXTENSION, + }); + }); + + it('should return DUPLICATE_LOCATION message and fieldName for DN number', () => { + const failure = {data: {reason: 'DUPLICATE_LOCATION'}} as Failure; + const result = Utils.getStationLoginErrorData(failure, LoginOption.AGENT_DN); + expect(result).toEqual({ + message: + 'Dial number is in use. Try a different one. For help, reach out to your administrator or support team.', + fieldName: LoginOption.AGENT_DN, + }); + }); + + it('should return INVALID_DIAL_NUMBER message and fieldName', () => { + const failure = {data: {reason: 'INVALID_DIAL_NUMBER'}} as Failure; + const result = Utils.getStationLoginErrorData(failure, LoginOption.AGENT_DN); + expect(result).toEqual({ + message: + 'Enter a valid US dial number. For help, reach out to your administrator or support team.', + fieldName: LoginOption.AGENT_DN, + }); + }); + + it('should return default message and fieldName for empty reason', () => { + const failure = {data: {reason: ''}} as Failure; + const result = Utils.getStationLoginErrorData(failure, LoginOption.EXTENSION); + expect(result).toEqual({ + message: 'An error occurred while logging in to the station', + fieldName: 'generic', + }); + }); + + it('should return default message and fieldName for missing reason', () => { + const failure = {data: {}} as Failure; + const result = Utils.getStationLoginErrorData(failure, LoginOption.EXTENSION); + expect(result).toEqual({ + message: 'An error occurred while logging in to the station', + fieldName: 'generic', + }); + }); + + it('should return default message and fieldName for unknown reason', () => { + const failure = {data: {reason: 'UNKNOWN_REASON'}} as Failure; + const result = Utils.getStationLoginErrorData(failure, LoginOption.EXTENSION); + expect(result).toEqual({ + message: 'An error occurred while logging in to the station', + fieldName: 'generic', + }); + }); + }); +}); diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/task/TaskManager.ts b/packages/@webex/plugin-cc/test/unit/spec/services/task/TaskManager.ts index ace4bcb673b..feee070c266 100644 --- a/packages/@webex/plugin-cc/test/unit/spec/services/task/TaskManager.ts +++ b/packages/@webex/plugin-cc/test/unit/spec/services/task/TaskManager.ts @@ -527,7 +527,7 @@ describe('TaskManager', () => { expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_HOLD, taskManager.getTask(taskId)); }); - it('should emit TASK_RESUME event on AGENT_CONTACT_UNHELD event', () => { + it('should emit TASK_UNHOLD event on AGENT_CONTACT_UNHELD event', () => { webSocketManagerMock.emit('message', JSON.stringify(initalPayload)); const payload = { @@ -551,7 +551,7 @@ describe('TaskManager', () => { const taskUpdateTaskDataSpy = jest.spyOn(taskManager.getTask(taskId), 'updateTaskData'); webSocketManagerMock.emit('message', JSON.stringify(payload)); expect(taskUpdateTaskDataSpy).toHaveBeenCalledWith(payload.data); - expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_RESUME, taskManager.getTask(taskId)); + expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_UNHOLD, taskManager.getTask(taskId)); }); it('handle AGENT_CONSULT_CREATED event', () => { diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/task/index.ts b/packages/@webex/plugin-cc/test/unit/spec/services/task/index.ts index 08232ea304e..ff7da768eab 100644 --- a/packages/@webex/plugin-cc/test/unit/spec/services/task/index.ts +++ b/packages/@webex/plugin-cc/test/unit/spec/services/task/index.ts @@ -1,7 +1,7 @@ import 'jsdom-global/register'; import {CALL_EVENT_KEYS, CallingClientConfig, LocalMicrophoneStream} from '@webex/calling'; import {LoginOption, WebexSDK} from '../../../../../src/types'; -import {CC_FILE} from '../../../../../src/constants'; +import {TASK_FILE} from '../../../../../src/constants'; import Task from '../../../../../src/services/task'; import * as Utils from '../../../../../src/services/core/Utils'; import {CC_EVENTS} from '../../../../../src/services/config/types'; @@ -21,8 +21,10 @@ import { import WebexRequest from '../../../../../src/services/core/WebexRequest'; import MetricsManager from '../../../../../src/metrics/MetricsManager'; import {METRIC_EVENT_NAMES} from '../../../../../src/metrics/constants'; +import LoggerProxy from '../../../../../src/logger-proxy'; jest.mock('@webex/calling'); +jest.mock('../../../../../src/logger-proxy'); describe('Task', () => { let onSpy; @@ -34,6 +36,9 @@ describe('Task', () => { let getErrorDetailsSpy; let mockWebexRequest; let webex: WebexSDK; + let loggerInfoSpy; + let loggerLogSpy; + let loggerErrorSpy; const taskId = '0ae913a4-c857-4705-8d49-76dd3dde75e4'; const mockTrack = {} as MediaStreamTrack; @@ -52,6 +57,10 @@ describe('Task', () => { }, } as unknown as WebexSDK; + loggerInfoSpy = jest.spyOn(LoggerProxy, 'info'); + loggerLogSpy = jest.spyOn(LoggerProxy, 'log'); + loggerErrorSpy = jest.spyOn(LoggerProxy, 'error'); + contactMock = { accept: jest.fn().mockResolvedValue({}), hold: jest.fn().mockResolvedValue({}), @@ -296,6 +305,19 @@ describe('Task', () => { expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalledWith({audio: true}); expect(LocalMicrophoneStream).toHaveBeenCalledWith(mockStream); expect(answerCallSpy).toHaveBeenCalledWith(expect.any(LocalMicrophoneStream), taskId); + expect(loggerInfoSpy).toHaveBeenCalledWith(`Accepting task`, { + module: TASK_FILE, + method: 'accept', + interactionId: task.data.interactionId, + }); + expect(loggerLogSpy).toHaveBeenCalledWith( + `Task accepted successfully with webrtc calling`, + { + module: TASK_FILE, + method: 'accept', + interactionId: task.data.interactionId, + } + ); expect(mockMetricsManager.trackEvent).toHaveBeenNthCalledWith( 1, METRIC_EVENT_NAMES.TASK_ACCEPT_SUCCESS, @@ -313,39 +335,81 @@ describe('Task', () => { it('should accept a task when mediaType chat', async () => { task.data.interaction.mediaType = 'chat'; const answerCallSpy = jest.spyOn(webCallingService, 'answerCall'); + const response = {}; + contactMock.accept.mockResolvedValue(response); await task.accept(); + expect(contactMock.accept).toHaveBeenCalledWith({ interactionId: taskId, }); expect(answerCallSpy).not.toHaveBeenCalled(); + expect(mockMetricsManager.timeEvent).toHaveBeenCalledWith([ + METRIC_EVENT_NAMES.TASK_ACCEPT_SUCCESS, + METRIC_EVENT_NAMES.TASK_ACCEPT_FAILED, + ]); + const expectedMetrics = { + taskId: task.data.interactionId, + agentId: task.data.agentId, + eventType: task.data.type, + notifTrackingId: task.data.trackingId, + orgId: task.data.orgId, + }; + expect(mockMetricsManager.trackEvent).toHaveBeenCalledWith( + METRIC_EVENT_NAMES.TASK_ACCEPT_SUCCESS, + expectedMetrics, + ['operational', 'behavioral', 'business'] + ); + expect(loggerInfoSpy).toHaveBeenCalledWith(`Accepting task`, { + module: TASK_FILE, + method: 'accept', + interactionId: task.data.interactionId, + }); + expect(loggerLogSpy).toHaveBeenCalledWith(`Task accepted successfully`, { + module: TASK_FILE, + method: 'accept', + interactionId: task.data.interactionId, + }); }); it('should accept a task when mediaType email', async () => { task.data.interaction.mediaType = 'email'; const answerCallSpy = jest.spyOn(webCallingService, 'answerCall'); + const response = {}; + contactMock.accept.mockResolvedValue(response); await task.accept(); + expect(contactMock.accept).toHaveBeenCalledWith({ interactionId: taskId, }); expect(answerCallSpy).not.toHaveBeenCalled(); - }); - - it('should call accept API for Extension login option', async () => { - webCallingService.loginOption = LoginOption.EXTENSION; - - await task.accept(); - - expect(contactMock.accept).toHaveBeenCalledWith({interactionId: taskId}); - expect(mockMetricsManager.trackEvent).toHaveBeenNthCalledWith( - 1, + expect(mockMetricsManager.timeEvent).toHaveBeenCalledWith([ METRIC_EVENT_NAMES.TASK_ACCEPT_SUCCESS, - { - taskId: taskDataMock.interactionId, - }, + METRIC_EVENT_NAMES.TASK_ACCEPT_FAILED, + ]); + const expectedMetrics = { + taskId: task.data.interactionId, + agentId: task.data.agentId, + eventType: task.data.type, + notifTrackingId: task.data.trackingId, + orgId: task.data.orgId, + }; + expect(mockMetricsManager.trackEvent).toHaveBeenCalledWith( + METRIC_EVENT_NAMES.TASK_ACCEPT_SUCCESS, + expectedMetrics, ['operational', 'behavioral', 'business'] ); + expect(loggerInfoSpy).toHaveBeenCalledWith(`Accepting task`, { + module: TASK_FILE, + method: 'accept', + interactionId: task.data.interactionId, + }); + expect(loggerLogSpy).toHaveBeenCalledWith(`Task accepted successfully`, { + module: TASK_FILE, + method: 'accept', + interactionId: task.data.interactionId, + }); }); it('should handle errors in accept method', async () => { @@ -363,7 +427,7 @@ describe('Task', () => { }); await expect(task.accept()).rejects.toThrow(new Error(error.details.data.reason)); - expect(getErrorDetailsSpy).toHaveBeenCalledWith(error, 'accept', CC_FILE); + expect(getErrorDetailsSpy).toHaveBeenCalledWith(error, 'accept', TASK_FILE); expect(mockMetricsManager.trackEvent).toHaveBeenNthCalledWith( 1, METRIC_EVENT_NAMES.TASK_ACCEPT_FAILED, @@ -384,6 +448,16 @@ describe('Task', () => { expect(declineCallSpy).toHaveBeenCalledWith(taskId); expect(offSpy).toHaveBeenCalledWith(CALL_EVENT_KEYS.REMOTE_MEDIA, offSpy.mock.calls[0][1]); + expect(loggerInfoSpy).toHaveBeenCalledWith(`Declining task`, { + module: TASK_FILE, + method: 'decline', + interactionId: task.data.interactionId, + }); + expect(loggerLogSpy).toHaveBeenCalledWith(`Task declined successfully`, { + module: TASK_FILE, + method: 'decline', + interactionId: task.data.interactionId, + }); expect(mockMetricsManager.trackEvent).toHaveBeenNthCalledWith( 1, METRIC_EVENT_NAMES.TASK_DECLINE_SUCCESS, @@ -408,7 +482,7 @@ describe('Task', () => { throw error; }); await expect(task.decline()).rejects.toThrow(new Error(error.details.data.reason)); - expect(getErrorDetailsSpy).toHaveBeenCalledWith(error, 'decline', CC_FILE); + expect(getErrorDetailsSpy).toHaveBeenCalledWith(error, 'decline', TASK_FILE); expect(mockMetricsManager.trackEvent).toHaveBeenNthCalledWith( 1, METRIC_EVENT_NAMES.TASK_DECLINE_FAILED, @@ -432,6 +506,16 @@ describe('Task', () => { data: {mediaResourceId: taskDataMock.mediaResourceId}, }); 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, @@ -458,7 +542,7 @@ describe('Task', () => { }); await expect(task.hold()).rejects.toThrow(error.details.data.reason); - expect(getErrorDetailsSpy).toHaveBeenCalledWith(error, 'hold', CC_FILE); + expect(getErrorDetailsSpy).toHaveBeenCalledWith(error, 'hold', TASK_FILE); expect(mockMetricsManager.trackEvent).toHaveBeenNthCalledWith( 1, METRIC_EVENT_NAMES.TASK_HOLD_FAILED, @@ -510,7 +594,7 @@ describe('Task', () => { }); await expect(task.resume()).rejects.toThrow(error.details.data.reason); - expect(getErrorDetailsSpy).toHaveBeenCalledWith(error, 'resume', CC_FILE); + expect(getErrorDetailsSpy).toHaveBeenCalledWith(error, 'resume', TASK_FILE); expect(mockMetricsManager.trackEvent).toHaveBeenNthCalledWith( 1, METRIC_EVENT_NAMES.TASK_RESUME_FAILED, @@ -528,18 +612,28 @@ describe('Task', () => { it('should initiate a consult call and return the expected response', async () => { const consultPayload = { - destination: '1234', + to: '1234', destinationType: DESTINATION_TYPE.AGENT, }; - const expectedResponse: TaskResponse = {data: {interactionId: taskId}} as AgentContact; + const expectedResponse: TaskResponse = {data: {interactionId: taskId}, trackingId: '1234'} as AgentContact; contactMock.consult.mockResolvedValue(expectedResponse); const response = await task.consult(consultPayload); expect(contactMock.consult).toHaveBeenCalledWith({interactionId: taskId, data: consultPayload}); expect(response).toEqual(expectedResponse); - expect(mockMetricsManager.trackEvent).toHaveBeenNthCalledWith( - 1, + expect(loggerInfoSpy).toHaveBeenCalledWith(`Starting consult`, { + module: TASK_FILE, + method: 'consult', + interactionId: task.data.interactionId, + }); + expect(loggerLogSpy).toHaveBeenCalledWith(`Consult started successfully to ${consultPayload.to}`, { + module: TASK_FILE, + method: 'consult', + trackingId: expectedResponse.trackingId, + interactionId: task.data.interactionId, + }); + expect(mockMetricsManager.trackEvent).toHaveBeenCalledWith( METRIC_EVENT_NAMES.TASK_CONSULT_START_SUCCESS, { taskId: taskDataMock.interactionId, @@ -565,14 +659,18 @@ describe('Task', () => { }); const consultPayload = { - destination: '1234', + to: '1234', destinationType: DESTINATION_TYPE.AGENT, }; await expect(task.consult(consultPayload)).rejects.toThrow(error.details.data.reason); - expect(getErrorDetailsSpy).toHaveBeenCalledWith(error, 'consult', CC_FILE); - expect(mockMetricsManager.trackEvent).toHaveBeenNthCalledWith( - 1, + expect(getErrorDetailsSpy).toHaveBeenCalledWith(error, 'consult', TASK_FILE); + expect(loggerInfoSpy).toHaveBeenCalledWith(`Starting consult`, { + module: TASK_FILE, + method: 'consult', + interactionId: task.data.interactionId, + }); + expect(mockMetricsManager.trackEvent).toHaveBeenCalledWith( METRIC_EVENT_NAMES.TASK_CONSULT_START_FAILED, { taskId: taskDataMock.interactionId, @@ -630,7 +728,7 @@ describe('Task', () => { }; await expect(task.endConsult(consultEndPayload)).rejects.toThrow(error.details.data.reason); - expect(getErrorDetailsSpy).toHaveBeenCalledWith(error, 'endConsult', CC_FILE); + expect(getErrorDetailsSpy).toHaveBeenCalledWith(error, 'endConsult', TASK_FILE); expect(mockMetricsManager.trackEvent).toHaveBeenNthCalledWith( 1, METRIC_EVENT_NAMES.TASK_CONSULT_END_FAILED, @@ -751,7 +849,7 @@ describe('Task', () => { await expect(task.consultTransfer(consultTransferPayload)).rejects.toThrow( error.details.data.reason ); - expect(getErrorDetailsSpy).toHaveBeenCalledWith(error, 'consultTransfer', CC_FILE); + expect(getErrorDetailsSpy).toHaveBeenCalledWith(error, 'consultTransfer', TASK_FILE); expect(mockMetricsManager.trackEvent).toHaveBeenNthCalledWith( 2, METRIC_EVENT_NAMES.TASK_TRANSFER_FAILED, @@ -846,7 +944,7 @@ describe('Task', () => { }; await expect(task.transfer(blindTransferPayload)).rejects.toThrow(error.details.data.reason); - expect(getErrorDetailsSpy).toHaveBeenCalledWith(error, 'transfer', CC_FILE); + expect(getErrorDetailsSpy).toHaveBeenCalledWith(error, 'transfer', TASK_FILE); expect(mockMetricsManager.trackEvent).toHaveBeenNthCalledWith( 1, METRIC_EVENT_NAMES.TASK_TRANSFER_FAILED, @@ -870,6 +968,16 @@ describe('Task', () => { expect(contactMock.end).toHaveBeenCalledWith({interactionId: taskId}); expect(response).toEqual(expectedResponse); + expect(loggerInfoSpy).toHaveBeenCalledWith(`Ending task`, { + module: TASK_FILE, + method: 'end', + interactionId: expectedResponse.data.interactionId, + }); + expect(loggerLogSpy).toHaveBeenCalledWith(`Task ended successfully`, { + module: TASK_FILE, + method: 'end', + interactionId: expectedResponse.data.interactionId, + }); expect(mockMetricsManager.trackEvent).toHaveBeenNthCalledWith( 1, METRIC_EVENT_NAMES.TASK_END_SUCCESS, @@ -895,7 +1003,7 @@ describe('Task', () => { }); await expect(task.end()).rejects.toThrow(error.details.data.reason); - expect(getErrorDetailsSpy).toHaveBeenCalledWith(error, 'end', CC_FILE); + expect(getErrorDetailsSpy).toHaveBeenCalledWith(error, 'end', TASK_FILE); expect(mockMetricsManager.trackEvent).toHaveBeenNthCalledWith( 1, METRIC_EVENT_NAMES.TASK_END_FAILED, @@ -951,7 +1059,7 @@ describe('Task', () => { }; await expect(task.wrapup(wrapupPayload)).rejects.toThrow(error.details.data.reason); - expect(getErrorDetailsSpy).toHaveBeenCalledWith(error, 'wrapup', CC_FILE); + expect(getErrorDetailsSpy).toHaveBeenCalledWith(error, 'wrapup', TASK_FILE); expect(mockMetricsManager.trackEvent).toHaveBeenNthCalledWith( 1, METRIC_EVENT_NAMES.TASK_WRAPUP_FAILED, @@ -995,6 +1103,16 @@ describe('Task', () => { await task.pauseRecording(); expect(contactMock.pauseRecording).toHaveBeenCalledWith({interactionId: taskId}); + expect(loggerInfoSpy).toHaveBeenCalledWith(`Pausing recording`, { + module: TASK_FILE, + method: 'pauseRecording', + interactionId: task.data.interactionId, + }); + expect(loggerLogSpy).toHaveBeenCalledWith(`Recording paused successfully`, { + module: TASK_FILE, + method: 'pauseRecording', + interactionId: task.data.interactionId, + }); expect(mockMetricsManager.trackEvent).toHaveBeenNthCalledWith( 1, METRIC_EVENT_NAMES.TASK_PAUSE_RECORDING_SUCCESS, @@ -1019,7 +1137,7 @@ describe('Task', () => { }); await expect(task.pauseRecording()).rejects.toThrow(error.details.data.reason); - expect(getErrorDetailsSpy).toHaveBeenCalledWith(error, 'pauseRecording', CC_FILE); + expect(getErrorDetailsSpy).toHaveBeenCalledWith(error, 'pauseRecording', TASK_FILE); expect(mockMetricsManager.trackEvent).toHaveBeenNthCalledWith( 1, METRIC_EVENT_NAMES.TASK_PAUSE_RECORDING_FAILED, @@ -1035,14 +1153,25 @@ describe('Task', () => { it('should resume the recording of the task', async () => { const resumePayload = { autoResumed: true, + interactionId: taskId, }; await task.resumeRecording(resumePayload); expect(contactMock.resumeRecording).toHaveBeenCalledWith({ - interactionId: taskId, + interactionId: resumePayload.interactionId, data: resumePayload, }); + expect(loggerInfoSpy).toHaveBeenCalledWith(`Resuming recording`, { + module: TASK_FILE, + method: 'resumeRecording', + interactionId: task.data.interactionId, + }); + expect(loggerLogSpy).toHaveBeenCalledWith(`Recording resumed successfully`, { + module: TASK_FILE, + method: 'resumeRecording', + interactionId: task.data.interactionId, + }); expect(mockMetricsManager.trackEvent).toHaveBeenNthCalledWith( 1, METRIC_EVENT_NAMES.TASK_RESUME_RECORDING_SUCCESS, @@ -1092,7 +1221,7 @@ describe('Task', () => { }; await expect(task.resumeRecording(resumePayload)).rejects.toThrow(error.details.data.reason); - expect(getErrorDetailsSpy).toHaveBeenCalledWith(error, 'resumeRecording', CC_FILE); + expect(getErrorDetailsSpy).toHaveBeenCalledWith(error, 'resumeRecording', TASK_FILE); expect(mockMetricsManager.trackEvent).toHaveBeenNthCalledWith( 1, METRIC_EVENT_NAMES.TASK_RESUME_RECORDING_FAILED, @@ -1112,6 +1241,16 @@ describe('Task', () => { await task.toggleMute(); expect(muteCallSpy).toHaveBeenCalledWith(mockStream); + expect(loggerInfoSpy).toHaveBeenCalledWith(`Toggling mute state`, { + module: TASK_FILE, + method: 'toggleMute', + interactionId: task.data.interactionId, + }); + expect(loggerLogSpy).toHaveBeenCalledWith(`Mute state toggled successfully isCallMuted: ${webCallingService.isCallMuted()}`, { + module: TASK_FILE, + method: 'toggleMute', + interactionId: task.data.interactionId, + }); }); it('should handle errors in mute method', async () => { @@ -1128,6 +1267,11 @@ describe('Task', () => { throw error; }); await expect(task.toggleMute()).rejects.toThrow(new Error(error.details.data.reason)); - expect(getErrorDetailsSpy).toHaveBeenCalledWith(error, 'mute', CC_FILE); + expect(getErrorDetailsSpy).toHaveBeenCalledWith(error, 'toggleMute', TASK_FILE); + expect(loggerInfoSpy).toHaveBeenCalledWith(`Toggling mute state`, { + module: TASK_FILE, + method: 'toggleMute', + interactionId: task.data.interactionId, + }); }); }); diff --git a/packages/@webex/plugin-cc/typedoc.json b/packages/@webex/plugin-cc/typedoc.json new file mode 100644 index 00000000000..f2c24b177e9 --- /dev/null +++ b/packages/@webex/plugin-cc/typedoc.json @@ -0,0 +1,37 @@ +{ + "name": "Contact Center Plugin", + "entryPoints": ["src/index.ts"], + "readme": "typedoc.md", + "entryPointStrategy": "expand", + "tsconfig": "tsconfig.json", + "out": "../../../docs/wxcc", + "excludePrivate": false, + "excludeExternals": true, + "exclude": ["**/node_modules/**", "**/test/unit/spec/**/*.ts"], + "hideGenerator": true, + "theme": "default", + "cleanOutputDir": true, + "includeVersion": true, + "disableSources": false, + "sourceLinkTemplate": "https://github.com/webex/webex-js-sdk/blob/master/{path}#L{line}", + "validation": { + "notExported": true, + "invalidLink": true, + "notDocumented": true + }, + "sort": ["kind", "visibility", "alphabetical"], + "categorizeByGroup": true, + "categoryOrder": ["Core", "Services", "Classes", "Interfaces", "Enum", "Types", "*"], + "navigationLinks": { + "Home": "/", + "NPM": "http://npmjs.com/package/@webex/plugin-cc", + "GitHub": "https://github.com/webex/webex-js-sdk" + }, + "searchInComments": true, + "visibilityFilters": { + "protected": true, + "private": false, + "inherited": true, + "external": true + } +} diff --git a/packages/@webex/plugin-cc/typedoc.md b/packages/@webex/plugin-cc/typedoc.md new file mode 100644 index 00000000000..a0c9e365a56 --- /dev/null +++ b/packages/@webex/plugin-cc/typedoc.md @@ -0,0 +1,86 @@ +# Webex JS SDK: Contact Center Plugin + +Welcome to the documentation for the **@webex/plugin-cc** package, part of the [Webex JS SDK](https://github.com/webex/webex-js-sdk). This plugin provides APIs and utilities for integrating Webex Contact Center features into your JavaScript applications. + +## Overview + +- **Modular**: Integrates seamlessly with the Webex JS SDK. +- **Feature-rich**: Access Contact Center capabilities such as agent management, task handling, and more. +- **Type-safe**: Built with TypeScript for robust type checking and developer experience. + +## Getting Started + +```bash +npm install @webex/plugin-cc +``` + +Add the plugin to your Webex SDK instance: + +```js +import Webex from '@webex/plugin-cc'; + +// Initialize Webex SDK with default configuration +const webex = Webex.init(); +``` + +Or, initialize with a custom configuration: + +```js +import Webex from '@webex/plugin-cc'; + +const customConfig = { + logger: { + level: 'debug', // Enable debug logging + bufferLogLevel: 'log', // Used for upload logs + }, + credentials: { + access_token: 'your-access-token', + }, + cc: { + allowMultiLogin: false, // Disallow multiple logins + allowAutomatedRelogin: true, // Enable automated re-login + clientType: 'WebexCCSDK', // Specify the Contact Center client type + isKeepAliveEnabled: false, // Disable keep-alive functionality + force: true, // Force CC-specific configurations + metrics: { + clientName: 'WEBEX_JS_SDK', // Metrics client name + clientType: 'WebexCCSDK', // Metrics client type + }, + }, +}; + +// Initialize Webex SDK with custom configuration +const webex = Webex.init({config: customConfig}); +``` + +For access token refer here. + +### Configuration Reference + +The `Webex.init` method accepts an optional configuration object to customize SDK behavior: + +| Option | Type | Default | Description | +| --------------------------------- | --------- | ---------------- | ----------------------------------------- | +| `config.logger.level` | `string` | `'info'` | Logging level (`'debug'`, `'info'`, etc.) | +| `config.logger.bufferLogLevel` | `string` | `'log'` | Log buffering level for uploads | +| `config.cc.allowMultiLogin` | `boolean` | `false` | Allow multiple logins | +| `config.cc.allowAutomatedRelogin` | `boolean` | `true` | Enable automated re-login | +| `config.cc.clientType` | `string` | `'WebexCCSDK'` | Type of the Contact Center client | +| `config.cc.isKeepAliveEnabled` | `boolean` | `false` | Enable keep-alive functionality | +| `config.cc.force` | `boolean` | `true` | Force CC-specific configurations | +| `config.cc.metrics.clientName` | `string` | `'WEBEX_JS_SDK'` | Metrics client name | +| `config.cc.metrics.clientType` | `string` | `'WebexCCSDK'` | Metrics client type | + +For a full list of configuration options, see the Webex Object Attribute Reference. + +## Class Hierarchy + +- [`Contact Center`](./classes/ContactCenter.html) - Click here if you want to learn more about `Agent based operations` such as station login, user state management, outdial, and related functionalities. + +- [`Task`](./classes/Task.html) - Click here to learn more about task-based operations such as mute, unmute, hold, and transfer + +## Support + +For issues and feature requests, please visit the GitHub repository + +--- diff --git a/packages/@webex/plugin-meetings/src/breakouts/index.ts b/packages/@webex/plugin-meetings/src/breakouts/index.ts index d6c49bc2237..bf20db3c2d6 100644 --- a/packages/@webex/plugin-meetings/src/breakouts/index.ts +++ b/packages/@webex/plugin-meetings/src/breakouts/index.ts @@ -45,6 +45,8 @@ const Breakouts = WebexPlugin.extend({ intervalID: 'number', meetingId: 'string', canManageBreakouts: 'boolean', // appear the ability to manage breakouts + mainGroupId: 'string', // appears from the moment you enable breakouts + mainSessionId: 'string', // appears from the moment you enable breakouts }, children: { currentBreakoutSession: Breakout, @@ -544,6 +546,28 @@ const Breakouts = WebexPlugin.extend({ } }, + /** + * set main group id + * @param {Object} breakoutInfo -- breakout groups + * @returns {void} + */ + _setMainGroupId(breakoutInfo) { + if (breakoutInfo?.body?.mainGroupId) { + this.set('mainGroupId', breakoutInfo.body.mainGroupId); + } + }, + + /** + * set main session id + * @param {Object} breakoutInfo -- breakout groups + * @returns {void} + */ + _setMainSessionId(breakoutInfo) { + if (breakoutInfo?.body?.mainSessionId) { + this.set('mainSessionId', breakoutInfo.body.mainSessionId); + } + }, + /** * Create new breakout sessions * @param {object} params -- breakout session group @@ -567,6 +591,8 @@ const Breakouts = WebexPlugin.extend({ }); this._setManageGroups(breakoutInfo); + this._setMainGroupId(breakoutInfo); + this._setMainSessionId(breakoutInfo); // clear edit lock info after save breakout session info this._clearEditLockInfo(); @@ -630,6 +656,8 @@ const Breakouts = WebexPlugin.extend({ }); this._setManageGroups(breakoutInfo); + this._setMainGroupId(breakoutInfo); + this._setMainSessionId(breakoutInfo); return breakoutInfo; }, @@ -665,6 +693,8 @@ const Breakouts = WebexPlugin.extend({ }); this._setManageGroups(breakoutInfo); + this._setMainGroupId(breakoutInfo); + this._setMainSessionId(breakoutInfo); return breakoutInfo; }, @@ -718,6 +748,9 @@ const Breakouts = WebexPlugin.extend({ }); this._setManageGroups(breakout); + this._setMainGroupId(breakout); + this._setMainSessionId(breakout); + if (editlock && breakout.body?.editlock?.token) { this.set('editLock', breakout.body.editlock); this.keepEditLockAlive(); @@ -916,6 +949,42 @@ const Breakouts = WebexPlugin.extend({ body, }); }, + /** + * Move participants to main session lobby + * @param {Array} sessions + * @param {string} sessions[].participants - Participant IDs to move + * @returns {void} + */ + moveToLobby(sessions: Array<{participants: string[]}>) { + if (!this.mainGroupId || !this.mainSessionId) { + throw new Error( + 'Main group ID and session ID must be available to move participants to lobby' + ); + } + + const updatedSessions = sessions.map((item) => { + return { + id: this.mainSessionId, + participants: item.participants, + targetState: 'LOBBY', + }; + }); + + const body = { + groups: [ + { + id: this.mainGroupId, + sessions: updatedSessions, + }, + ], + }; + + return this.request({ + method: HTTP_VERBS.PUT, + uri: `${this.url}/dynamicAssign`, + body, + }); + }, /** * trigger ASK_RETURN_TO_MAIN event when main session requested * @param {Object} breakout diff --git a/packages/@webex/plugin-meetings/src/config.ts b/packages/@webex/plugin-meetings/src/config.ts index acaa32d0143..b4635d601f7 100644 --- a/packages/@webex/plugin-meetings/src/config.ts +++ b/packages/@webex/plugin-meetings/src/config.ts @@ -98,5 +98,6 @@ export default { enableReachabilityChecks: true, reachabilityGetClusterTimeout: 5000, logUploadIntervalMultiplicationFactor: 0, // if set to 0 or undefined, logs won't be uploaded periodically, if you want periodic logs, recommended value is 1 + stopIceGatheringAfterFirstRelayCandidate: false, }, }; diff --git a/packages/@webex/plugin-meetings/src/constants.ts b/packages/@webex/plugin-meetings/src/constants.ts index 6b8a1ca8de5..94660e43551 100644 --- a/packages/@webex/plugin-meetings/src/constants.ts +++ b/packages/@webex/plugin-meetings/src/constants.ts @@ -381,6 +381,7 @@ export const EVENT_TRIGGERS = { MEETING_STOPPED_RECEIVING_TRANSCRIPTION: 'meeting:receiveTranscription:stopped', MEETING_MANUAL_CAPTION_UPDATED: 'meeting:manualCaptionControl:updated', MEETING_CAPTION_RECEIVED: 'meeting:caption-received', + MEETING_PARTICIPANT_REASON_CHANGED: 'meeting:participant-reason-changed', }; export const EVENT_TYPES = { @@ -743,6 +744,7 @@ export const LOCUSINFO = { MEDIA_INACTIVITY: 'MEDIA_INACTIVITY', LINKS_SERVICES: 'LINKS_SERVICES', LINKS_RESOURCES: 'LINKS_RESOURCES', + PARTICIPANT_REASON_CHANGED: 'PARTICIPANT_REASON_CHANGED', }, }; diff --git a/packages/@webex/plugin-meetings/src/locus-info/index.ts b/packages/@webex/plugin-meetings/src/locus-info/index.ts index 72b04d01593..ef81aa5ab0b 100644 --- a/packages/@webex/plugin-meetings/src/locus-info/index.ts +++ b/packages/@webex/plugin-meetings/src/locus-info/index.ts @@ -1,4 +1,4 @@ -import {isEqual, assignWith, cloneDeep, isEmpty} from 'lodash'; +import {isEqual, assignWith, cloneDeep, isEmpty, forEach} from 'lodash'; import LoggerProxy from '../common/logs/logger-proxy'; import EventsScope from '../common/events/events-scope'; @@ -264,7 +264,7 @@ export default class LocusInfo extends EventsScope { this.updateMeetingInfo(locus.info); this.updateEmbeddedApps(locus.embeddedApps); // self and participants generate sipUrl for 1:1 meeting - this.updateSelf(locus.self, locus.participants); + this.updateSelf(locus.self); this.updateHostInfo(locus.host); this.updateMediaShares(locus.mediaShares); this.updateServices(locus.links?.services); @@ -422,6 +422,7 @@ export default class LocusInfo extends EventsScope { */ onDeltaLocus(locus: any) { const isReplaceMembers = ControlsUtils.isNeedReplaceMembers(this.controls, locus.controls); + this.mergeParticipants(this.participants, locus.participants); this.updateLocusInfo(locus); this.updateParticipants(locus.participants, isReplaceMembers); this.isMeetingActive(); @@ -449,7 +450,7 @@ export default class LocusInfo extends EventsScope { this.updateMediaShares(locus.mediaShares); this.updateParticipantsUrl(locus.participantsUrl); this.updateReplace(locus.replace); - this.updateSelf(locus.self, locus.participants); + this.updateSelf(locus.self); this.updateLocusUrl(locus.url); this.updateAclUrl(locus.aclUrl); this.updateBasequence(locus.baseSequence); @@ -783,6 +784,23 @@ export default class LocusInfo extends EventsScope { isReplace, } ); + + if (participants && Array.isArray(participants) && participants.length > 0) { + for (const participant of participants) { + if (participant && participant?.reason === 'FAILURE') { + this.emitScoped( + { + file: 'locus-info', + function: 'updateParticipants', + }, + LOCUSINFO.EVENTS.PARTICIPANT_REASON_CHANGED, + { + displayName: participant?.person?.primaryDisplayString, + } + ); + } + } + } } /** @@ -1357,17 +1375,20 @@ export default class LocusInfo extends EventsScope { /** * handles when the locus.self is updated - * @param {Object} self the locus.mediaShares property - * @param {Array} participants the locus.participants property + * @param {Object} self the new locus.self * @returns {undefined} * @memberof LocusInfo * emits internal events self_admitted_guest, self_unadmitted_guest, locus_info_update_self */ - updateSelf(self: any, participants: Array) { - // @ts-ignore - check where this.self come from - if (self && !isEqual(this.self, self)) { + updateSelf(self: any) { + if (self) { // @ts-ignore - const parsedSelves = SelfUtils.getSelves(this.self, self, this.webex.internal.device.url); + const parsedSelves = SelfUtils.getSelves( + this.parsedLocus.self, + self, + this.webex.internal.device.url, + this.participants // using this.participants instead of locus.participants here, because with delta DTOs locus.participants will only contain a small subset of participants + ); this.updateMeeting(parsedSelves.current); this.parsedLocus.self = parsedSelves.current; @@ -1381,7 +1402,7 @@ export default class LocusInfo extends EventsScope { // TODO: check if we need to save the sipUri here as well // this.emit(LOCUSINFO.EVENTS.MEETING_UPDATE, SelfUtils.getSipUrl(this.getLocusPartner(participants, self), this.parsedLocus.fullState.type, this.parsedLocus.info.sipUri)); const result = SelfUtils.getSipUrl( - this.getLocusPartner(participants, self), + this.getLocusPartner(this.participants, self), this.parsedLocus.fullState.type, this.parsedLocus.info.sipUri ); @@ -1527,7 +1548,7 @@ export default class LocusInfo extends EventsScope { {} ); } - if (parsedSelves.updates.isUserUnadmitted) { + if (parsedSelves.updates.hasUserEnteredLobby) { this.emitScoped( { file: 'locus-info', @@ -1537,7 +1558,7 @@ export default class LocusInfo extends EventsScope { self ); } - if (parsedSelves.updates.isUserAdmitted) { + if (parsedSelves.updates.hasUserBeenAdmitted) { this.emitScoped( { file: 'locus-info', diff --git a/packages/@webex/plugin-meetings/src/locus-info/selfUtils.ts b/packages/@webex/plugin-meetings/src/locus-info/selfUtils.ts index e2db5df6b1f..f8986f7860b 100644 --- a/packages/@webex/plugin-meetings/src/locus-info/selfUtils.ts +++ b/packages/@webex/plugin-meetings/src/locus-info/selfUtils.ts @@ -24,11 +24,17 @@ const SelfUtils = { * parses the relevant values for self: muted, guest, moderator, mediaStatus, state, joinedWith, pstnDevices, creator, id * @param {Object} self * @param {String} deviceId + * @param {Array} participants * @returns {undefined} */ - parse: (self: any, deviceId: string) => { + parse: (self: any, deviceId: string, participants: Array) => { if (self) { const joinedWith = self.devices.find((device) => deviceId === device.url); + const pairedWith = + joinedWith?.intent?.type === _OBSERVE_ && + participants?.find((participant) => participant.url === joinedWith?.intent?.associatedWith) + ?.devices[0]; + const pstnDevices = self.devices.filter((device) => PSTN_DEVICE_TYPE === device.deviceType); return { @@ -50,6 +56,7 @@ const SelfUtils = { // TODO: give a proper name . With same device as login or different login` // Some times we might have joined with both mobile and web joinedWith, + pairedWith, pstnDevices, // current media stats is for the current device who has joined currentMediaStatus: SelfUtils.getMediaStatus(joinedWith?.mediaSessions), @@ -59,7 +66,7 @@ const SelfUtils = { selfUrl: self.url, removed: self.removed, roles: SelfUtils.getRoles(self), - isUserUnadmitted: self.state === _IDLE_ && joinedWith?.intent?.type === _WAIT_, + isUserUnadmitted: SelfUtils.isLocusUserUnadmitted(self?.state, joinedWith, pairedWith), layout: SelfUtils.getLayout(self), canNotViewTheParticipantList: SelfUtils.canNotViewTheParticipantList(self), isSharingBlocked: SelfUtils.isSharingBlocked(self), @@ -94,13 +101,13 @@ const SelfUtils = { isSharingBlocked: (self) => !!self?.isSharingBlocked, - getSelves: (oldSelf, newSelf, deviceId) => { - const previous = oldSelf && SelfUtils.parse(oldSelf, deviceId); - const current = newSelf && SelfUtils.parse(newSelf, deviceId); + getSelves: (oldParsedSelf, newSelf, deviceId, participants: Array) => { + const previous = oldParsedSelf; + const current = newSelf && SelfUtils.parse(newSelf, deviceId, participants); const updates: any = {}; - updates.isUserUnadmitted = SelfUtils.isUserUnadmitted(previous, current); - updates.isUserAdmitted = SelfUtils.isUserAdmitted(previous, current); + updates.hasUserEnteredLobby = SelfUtils.hasUserEnteredLobby(previous, current); + updates.hasUserBeenAdmitted = SelfUtils.hasUserBeenAdmitted(previous, current); updates.isVideoMutedByOthersChanged = SelfUtils.videoMutedByOthersChanged(previous, current); updates.isMutedByOthersChanged = SelfUtils.mutedByOthersChanged(previous, current); updates.localAudioUnmuteRequestedByServer = SelfUtils.localAudioUnmuteRequestedByServer( @@ -316,37 +323,68 @@ const SelfUtils = { changedSelf.joinedWith.reason === MEETING_END_REASON.MEDIA_RELEASED), /** - * @param {Object} check - * @returns {Boolean} + * @param {String | undefined} state meeting state + * @param {any} joinedWith device that user has joined with + * @param {any} pairedWith device that user is paired with + * @returns {Boolean | undefined} true if user is in lobby, false if not, undefined if it cannot be determined */ - isLocusUserUnadmitted: (check: any) => - check && check.joinedWith?.intent?.type === _WAIT_ && check.state === _IDLE_, + isLocusUserUnadmitted: (state?: string, joinedWith?: any, pairedWith?: any) => { + if (state === undefined) { + return undefined; + } + if (joinedWith?.intent?.type === _OBSERVE_ && pairedWith) { + // we are paired with a device, so need to check the lobby state for that device + return pairedWith.intent?.type === _WAIT_ && state === _IDLE_; + } + + return joinedWith?.intent?.type === _WAIT_ && state === _IDLE_; + }, /** - * @param {Object} check + * @param {String | undefined} state meeting state + * @param {any} joinedWith device that user has joined with + * @param {any} pairedWith device that user is paired with * @returns {Boolean} */ - isLocusUserAdmitted: (check: any) => - check && check.joinedWith?.intent?.type !== _WAIT_ && check.state === _JOINED_, + isLocusUserAdmitted: (state?: string, joinedWith?: any, pairedWith?: any) => { + if (state === undefined) { + return undefined; + } + + if (joinedWith?.intent?.type === _OBSERVE_ && pairedWith) { + // we are paired with a device, so need to check the lobby state for that device + return pairedWith.intent?.type !== _WAIT_ && state === _JOINED_; + } + + return joinedWith?.intent?.type !== _WAIT_ && state === _JOINED_; + }, /** * @param {Object} oldSelf * @param {Object} changedSelf - * @returns {Boolean} + * @returns {Boolean} true if user has just been placed in the lobby * @throws {Error} when self is undefined */ - isUserUnadmitted: (oldSelf: any, changedSelf: any) => { + hasUserEnteredLobby: (oldSelf: any, changedSelf: any) => { if (!changedSelf) { throw new ParameterError( 'changedSelf must be defined to determine if self is unadmitted as guest.' ); } - if (SelfUtils.isLocusUserUnadmitted(oldSelf)) { - return false; - } + const wasInLobby = SelfUtils.isLocusUserUnadmitted( + oldSelf?.state, + oldSelf?.joinedWith, + oldSelf?.pairedWith + ); + + const isInLobby = SelfUtils.isLocusUserUnadmitted( + changedSelf?.state, + changedSelf?.joinedWith, + changedSelf?.pairedWith + ); - return SelfUtils.isLocusUserUnadmitted(changedSelf); + return !wasInLobby && isInLobby; }, moderatorChanged: (oldSelf, changedSelf) => { @@ -391,10 +429,10 @@ const SelfUtils = { /** * @param {Object} oldSelf * @param {Object} changedSelf - * @returns {Boolean} + * @returns {Boolean} true if the user has just been admitted from lobby into the meeting * @throws {Error} if changed self was undefined */ - isUserAdmitted: (oldSelf: object, changedSelf: object) => { + hasUserBeenAdmitted: (oldSelf: any, changedSelf: any) => { if (!oldSelf) { // if there was no previous locus, it couldn't have been admitted yet return false; @@ -405,7 +443,19 @@ const SelfUtils = { ); } - return SelfUtils.isLocusUserUnadmitted(oldSelf) && SelfUtils.isLocusUserAdmitted(changedSelf); + const wasInLobby = SelfUtils.isLocusUserUnadmitted( + oldSelf?.state, + oldSelf?.joinedWith, + oldSelf?.pairedWith + ); + + const isAdmitted = SelfUtils.isLocusUserAdmitted( + changedSelf?.state, + changedSelf?.joinedWith, + changedSelf?.pairedWith + ); + + return wasInLobby && isAdmitted && isAdmitted !== undefined; }, videoMutedByOthersChanged: (oldSelf, changedSelf) => { diff --git a/packages/@webex/plugin-meetings/src/media/index.ts b/packages/@webex/plugin-meetings/src/media/index.ts index a4072f9c14b..2274238cfee 100644 --- a/packages/@webex/plugin-meetings/src/media/index.ts +++ b/packages/@webex/plugin-meetings/src/media/index.ts @@ -16,6 +16,7 @@ import { LocalMicrophoneStream, } from '@webex/media-helpers'; import {RtcMetrics} from '@webex/internal-plugin-metrics'; +import {BrowserInfo} from '@webex/web-capabilities'; import LoggerProxy from '../common/logs/logger-proxy'; import {MEDIA_TRACK_CONSTRAINT} from '../constants'; import Config from '../config'; @@ -143,6 +144,7 @@ Media.createMediaConnection = ( bundlePolicy?: BundlePolicy; iceCandidatesTimeout?: number; disableAudioMainDtx?: boolean; + stopIceGatheringAfterFirstRelayCandidate?: boolean; } ) => { const { @@ -155,6 +157,7 @@ Media.createMediaConnection = ( bundlePolicy, iceCandidatesTimeout, disableAudioMainDtx, + stopIceGatheringAfterFirstRelayCandidate, } = options; const iceServers = []; @@ -182,6 +185,12 @@ Media.createMediaConnection = ( config.disableAudioMainDtx = disableAudioMainDtx; } + if (BrowserInfo.isFirefox()) { + config.doFullIce = true; + + config.stopIceGatheringAfterFirstRelayCandidate = stopIceGatheringAfterFirstRelayCandidate; + } + return new MultistreamRoapMediaConnection( config, meetingId, diff --git a/packages/@webex/plugin-meetings/src/meeting/brbState.ts b/packages/@webex/plugin-meetings/src/meeting/brbState.ts index 5b0f8fe56e9..532c35cf1f5 100644 --- a/packages/@webex/plugin-meetings/src/meeting/brbState.ts +++ b/packages/@webex/plugin-meetings/src/meeting/brbState.ts @@ -95,6 +95,13 @@ export class BrbState { return this.sendLocalBrbStateToServer(sendSlotManager) .then(() => { this.state.syncToServerInProgress = false; + + // This is a workaround for the fact that the server does not send the brb state + // in the locus update when a user joins from multiple devices but not all devices are requested brb. + // In the future, this could be improved with a new brb locus update handler + // https://jira-eng-gpk2.cisco.com/jira/browse/SPARK-655626 + this.handleServerBrbUpdate(this.state.client.enabled); + LoggerProxy.logger.info( `Meeting:brbState#applyClientStateToServer: sync with server completed` ); diff --git a/packages/@webex/plugin-meetings/src/meeting/index.ts b/packages/@webex/plugin-meetings/src/meeting/index.ts index f4492657167..4e2a0c7bd08 100644 --- a/packages/@webex/plugin-meetings/src/meeting/index.ts +++ b/packages/@webex/plugin-meetings/src/meeting/index.ts @@ -2637,6 +2637,19 @@ export default class Meeting extends StatelessWebexPlugin { this.locusInfo.on(EVENTS.LOCUS_INFO_UPDATE_PARTICIPANTS, (payload) => { this.members.locusParticipantsUpdate(payload); }); + this.locusInfo.on(LOCUSINFO.EVENTS.PARTICIPANT_REASON_CHANGED, (payload) => { + Trigger.trigger( + this, + { + file: 'meeting/index', + function: 'setUpLocusParticipantsListener', + }, + EVENT_TRIGGERS.MEETING_PARTICIPANT_REASON_CHANGED, + { + payload, + } + ); + }); } /** @@ -3803,6 +3816,18 @@ export default class Meeting extends StatelessWebexPlugin { return this.members.cancelPhoneInvite(invitee); } + /** + * Cancel an SIP call invitation made during a meeting + * @param {Object} invitee + * @param {String} invitee.memberId + * @returns {Promise} see #members.cancelSIPInvite + * @public + * @memberof Meeting + */ + public cancelSIPInvite(invitee: {memberId: string}) { + return this.members.cancelSIPInvite(invitee); + } + /** * Admit the guest(s) to the call once they are waiting. * If the host/cohost is in a breakout session, the locus url @@ -7022,6 +7047,9 @@ export default class Meeting extends StatelessWebexPlugin { iceCandidatesTimeout: this.config.iceCandidatesGatheringTimeout, // @ts-ignore - config coming from registerPlugin disableAudioMainDtx: this.config.experimental.disableAudioMainDtx, + stopIceGatheringAfterFirstRelayCandidate: + // @ts-ignore - config coming from registerPlugin + this.config.stopIceGatheringAfterFirstRelayCandidate, } ); diff --git a/packages/@webex/plugin-meetings/src/meetings/index.ts b/packages/@webex/plugin-meetings/src/meetings/index.ts index a9b13ab2861..6c07c532182 100644 --- a/packages/@webex/plugin-meetings/src/meetings/index.ts +++ b/packages/@webex/plugin-meetings/src/meetings/index.ts @@ -827,6 +827,27 @@ export default class Meetings extends WebexPlugin { } } + /** + * API to toggle stopping ICE Candidates Gathering after first relay candidate, + * needs to be called before webex.meetings.joinWithMedia() + * + * @param {Boolean} newValue + * @private + * @memberof Meetings + * @returns {undefined} + */ + private _toggleStopIceGatheringAfterFirstRelayCandidate(newValue: boolean) { + if (typeof newValue !== 'boolean') { + return; + } + + // @ts-ignore + if (this.config.stopIceGatheringAfterFirstRelayCandidate !== newValue) { + // @ts-ignore + this.config.stopIceGatheringAfterFirstRelayCandidate = newValue; + } + } + /** * Executes a registration step and updates the registration status. * @param {Function} step - The registration step to execute. diff --git a/packages/@webex/plugin-meetings/src/member/index.ts b/packages/@webex/plugin-meetings/src/member/index.ts index bc104998941..320ab506012 100644 --- a/packages/@webex/plugin-meetings/src/member/index.ts +++ b/packages/@webex/plugin-meetings/src/member/index.ts @@ -2,7 +2,7 @@ * Copyright (c) 2015-2020 Cisco Systems, Inc. See LICENSE file. */ import {MEETINGS, _IN_LOBBY_, _NOT_IN_MEETING_, _IN_MEETING_} from '../constants'; -import {IExternalRoles, IMediaStatus, ParticipantWithRoles} from './types'; +import {IExternalRoles, IMediaStatus, ParticipantWithBrb, ParticipantWithRoles} from './types'; import MemberUtil from './util'; @@ -312,7 +312,7 @@ export default class Member { this.supportsInterpretation = MemberUtil.isInterpretationSupported(participant); this.supportLiveAnnotation = MemberUtil.isLiveAnnotationSupported(participant); this.isGuest = MemberUtil.isGuest(participant); - this.isBrb = MemberUtil.isBrb(participant); + this.isBrb = MemberUtil.isBrb(participant as ParticipantWithBrb); this.isUser = MemberUtil.isUser(participant); this.isDevice = MemberUtil.isDevice(participant); this.isModerator = MemberUtil.isModerator(participant); diff --git a/packages/@webex/plugin-meetings/src/member/util.ts b/packages/@webex/plugin-meetings/src/member/util.ts index 6a09a8b538d..b63dcd35b1e 100644 --- a/packages/@webex/plugin-meetings/src/member/util.ts +++ b/packages/@webex/plugin-meetings/src/member/util.ts @@ -27,384 +27,384 @@ import { } from '../constants'; import ParameterError from '../common/errors/parameter'; -const MemberUtil: any = {}; - -/** - * @param {Object} participant - The locus participant object. - * @returns {Boolean} - */ -MemberUtil.canReclaimHost = (participant) => { - if (!participant) { - throw new ParameterError( - 'canReclaimHostRole could not be processed, participant is undefined.' - ); - } - - return participant.canReclaimHostRole || false; -}; - -/** - * @param {Object} participant - The locus participant object. - * @returns {[ServerRoleShape]} - */ -MemberUtil.getControlsRoles = (participant: ParticipantWithRoles): Array => - participant?.controls?.role?.roles; - -/** - * Checks if the participant has the brb status enabled. - * - * @param {ParticipantWithBrb} participant - The locus participant object. - * @returns {boolean} - True if the participant has brb enabled, false otherwise. - */ -MemberUtil.isBrb = (participant: ParticipantWithBrb): boolean => - participant.controls?.brb?.enabled || false; - -/** - * @param {Object} participant - The locus participant object. - * @param {ServerRoles} controlRole the search role - * @returns {Boolean} - */ -MemberUtil.hasRole = (participant: any, controlRole: ServerRoles): boolean => - MemberUtil.getControlsRoles(participant)?.some( - (role) => role.type === controlRole && role.hasRole - ); - -/** - * @param {Object} participant - The locus participant object. - * @returns {Boolean} - */ -MemberUtil.hasCohost = (participant: ParticipantWithRoles): boolean => - MemberUtil.hasRole(participant, ServerRoles.Cohost) || false; - -/** - * @param {Object} participant - The locus participant object. - * @returns {Boolean} - */ -MemberUtil.hasModerator = (participant: ParticipantWithRoles): boolean => - MemberUtil.hasRole(participant, ServerRoles.Moderator) || false; - -/** - * @param {Object} participant - The locus participant object. - * @returns {Boolean} - */ -MemberUtil.hasPresenter = (participant: ParticipantWithRoles): boolean => - MemberUtil.hasRole(participant, ServerRoles.Presenter) || false; - -/** - * @param {Object} participant - The locus participant object. - * @returns {IExternalRoles} - */ -MemberUtil.extractControlRoles = (participant: ParticipantWithRoles): IExternalRoles => { - const roles = { - cohost: MemberUtil.hasCohost(participant), - moderator: MemberUtil.hasModerator(participant), - presenter: MemberUtil.hasPresenter(participant), - }; - - return roles; -}; +const MemberUtil = { + /** + * @param {Object} participant - The locus participant object. + * @returns {Boolean} + */ + canReclaimHost: (participant) => { + if (!participant) { + throw new ParameterError( + 'canReclaimHostRole could not be processed, participant is undefined.' + ); + } -/** - * @param {Object} participant - The locus participant object. - * @returns {Boolean} - */ -MemberUtil.isUser = (participant: any) => participant && participant.type === _USER_; - -MemberUtil.isModerator = (participant) => participant && participant.moderator; - -/** - * @param {Object} participant - The locus participant object. - * @returns {Boolean} - */ -MemberUtil.isGuest = (participant: any) => participant && participant.guest; - -/** - * @param {Object} participant - The locus participant object. - * @returns {Boolean} - */ -MemberUtil.isDevice = (participant: any) => participant && participant.type === _RESOURCE_ROOM_; - -MemberUtil.isModeratorAssignmentProhibited = (participant) => - participant && participant.moderatorAssignmentNotAllowed; - -MemberUtil.isPresenterAssignmentProhibited = (participant) => - participant && participant.presenterAssignmentNotAllowed; - -/** - * checks to see if the participant id is the same as the passed id - * there are multiple ids that can be used - * @param {Object} participant - The locus participant object. - * @param {String} id - * @returns {Boolean} - */ -MemberUtil.isSame = (participant: any, id: string) => - participant && (participant.id === id || (participant.person && participant.person.id === id)); - -/** - * checks to see if the participant id is the same as the passed id for associated devices - * there are multiple ids that can be used - * @param {Object} participant - The locus participant object. - * @param {String} id - * @returns {Boolean} - */ -MemberUtil.isAssociatedSame = (participant: any, id: string) => - participant && - participant.associatedUsers && - participant.associatedUsers.some( - (user) => user.id === id || (user.person && user.person.id === id) - ); - -/** - * @param {Object} participant - The locus participant object. - * @param {Boolean} isGuest - * @param {String} status - * @returns {Boolean} - */ -MemberUtil.isNotAdmitted = (participant: any, isGuest: boolean, status: string): boolean => - participant && - participant.guest && - ((participant.devices && - participant.devices[0] && - participant.devices[0].intent && - participant.devices[0].intent.type === _WAIT_ && - // @ts-ignore - isGuest && - status === _IN_LOBBY_) || - // @ts-ignore - !status === _IN_MEETING_); - -/** - * @param {Object} participant - The locus participant object. - * @returns {Boolean} - */ -MemberUtil.isAudioMuted = (participant: any) => { - if (!participant) { - throw new ParameterError('Audio could not be processed, participant is undefined.'); - } - - return MemberUtil.isMuted(participant, AUDIO_STATUS, AUDIO); -}; + return participant.canReclaimHostRole || false; + }, + + /** + * @param {Object} participant - The locus participant object. + * @returns {[ServerRoleShape]} + */ + getControlsRoles: (participant: ParticipantWithRoles): Array => + participant?.controls?.role?.roles, + + /** + * Checks if the participant has the brb status enabled. + * + * @param {ParticipantWithBrb} participant - The locus participant object. + * @returns {boolean} - True if the participant has brb enabled, false otherwise. + */ + isBrb: (participant: ParticipantWithBrb): boolean => participant.controls?.brb?.enabled || false, + + /** + * @param {Object} participant - The locus participant object. + * @param {ServerRoles} controlRole the search role + * @returns {Boolean} + */ + hasRole: (participant: any, controlRole: ServerRoles): boolean => + MemberUtil.getControlsRoles(participant)?.some( + (role) => role.type === controlRole && role.hasRole + ), + + /** + * @param {Object} participant - The locus participant object. + * @returns {Boolean} + */ + hasCohost: (participant: ParticipantWithRoles): boolean => + MemberUtil.hasRole(participant, ServerRoles.Cohost) || false, + + /** + * @param {Object} participant - The locus participant object. + * @returns {Boolean} + */ + hasModerator: (participant: ParticipantWithRoles): boolean => + MemberUtil.hasRole(participant, ServerRoles.Moderator) || false, + + /** + * @param {Object} participant - The locus participant object. + * @returns {Boolean} + */ + hasPresenter: (participant: ParticipantWithRoles): boolean => + MemberUtil.hasRole(participant, ServerRoles.Presenter) || false, + + /** + * @param {Object} participant - The locus participant object. + * @returns {IExternalRoles} + */ + extractControlRoles: (participant: ParticipantWithRoles): IExternalRoles => { + const roles = { + cohost: MemberUtil.hasCohost(participant), + moderator: MemberUtil.hasModerator(participant), + presenter: MemberUtil.hasPresenter(participant), + }; + + return roles; + }, + + /** + * @param {Object} participant - The locus participant object. + * @returns {Boolean} + */ + isUser: (participant: any) => participant && participant.type === _USER_, + + isModerator: (participant) => participant && participant.moderator, + + /** + * @param {Object} participant - The locus participant object. + * @returns {Boolean} + */ + isGuest: (participant: any) => participant && participant.guest, + + /** + * @param {Object} participant - The locus participant object. + * @returns {Boolean} + */ + isDevice: (participant: any) => participant && participant.type === _RESOURCE_ROOM_, + + isModeratorAssignmentProhibited: (participant) => + participant && participant.moderatorAssignmentNotAllowed, + + isPresenterAssignmentProhibited: (participant) => + participant && participant.presenterAssignmentNotAllowed, + + /** + * checks to see if the participant id is the same as the passed id + * there are multiple ids that can be used + * @param {Object} participant - The locus participant object. + * @param {String} id + * @returns {Boolean} + */ + isSame: (participant: any, id: string) => + participant && (participant.id === id || (participant.person && participant.person.id === id)), + + /** + * checks to see if the participant id is the same as the passed id for associated devices + * there are multiple ids that can be used + * @param {Object} participant - The locus participant object. + * @param {String} id + * @returns {Boolean} + */ + isAssociatedSame: (participant: any, id: string) => + participant && + participant.associatedUsers && + participant.associatedUsers.some( + (user) => user.id === id || (user.person && user.person.id === id) + ), + + /** + * @param {Object} participant - The locus participant object. + * @param {Boolean} isGuest + * @param {String} status + * @returns {Boolean} + */ + isNotAdmitted: (participant: any, isGuest: boolean, status: string): boolean => + participant && + participant.guest && + ((participant.devices && + participant.devices[0] && + participant.devices[0].intent && + participant.devices[0].intent.type === _WAIT_ && + // @ts-ignore + isGuest && + status === _IN_LOBBY_) || + // @ts-ignore + !status === _IN_MEETING_), + + /** + * @param {Object} participant - The locus participant object. + * @returns {Boolean} + */ + isAudioMuted: (participant: any) => { + if (!participant) { + throw new ParameterError('Audio could not be processed, participant is undefined.'); + } -/** - * @param {Object} participant - The locus participant object. - * @returns {Boolean} - */ -MemberUtil.isVideoMuted = (participant: any): boolean => { - if (!participant) { - throw new ParameterError('Video could not be processed, participant is undefined.'); - } + return MemberUtil.isMuted(participant, AUDIO_STATUS, AUDIO); + }, - return MemberUtil.isMuted(participant, VIDEO_STATUS, VIDEO); -}; + /** + * @param {Object} participant - The locus participant object. + * @returns {Boolean} + */ + isVideoMuted: (participant: any): boolean => { + if (!participant) { + throw new ParameterError('Video could not be processed, participant is undefined.'); + } -/** - * @param {Object} participant - The locus participant object. - * @returns {Boolean} - */ -MemberUtil.isHandRaised = (participant: any) => { - if (!participant) { - throw new ParameterError('Raise hand could not be processed, participant is undefined.'); - } + return MemberUtil.isMuted(participant, VIDEO_STATUS, VIDEO); + }, - return participant.controls?.hand?.raised || false; -}; + /** + * @param {Object} participant - The locus participant object. + * @returns {Boolean} + */ + isHandRaised: (participant: any) => { + if (!participant) { + throw new ParameterError('Raise hand could not be processed, participant is undefined.'); + } -/** - * @param {Object} participant - The locus participant object. - * @returns {Boolean} - */ -MemberUtil.isBreakoutsSupported = (participant) => { - if (!participant) { - throw new ParameterError('Breakout support could not be processed, participant is undefined.'); - } + return participant.controls?.hand?.raised || false; + }, + + /** + * @param {Object} participant - The locus participant object. + * @returns {Boolean} + */ + isBreakoutsSupported: (participant) => { + if (!participant) { + throw new ParameterError( + 'Breakout support could not be processed, participant is undefined.' + ); + } - return !participant.doesNotSupportBreakouts; -}; + return !participant.doesNotSupportBreakouts; + }, + + /** + * @param {Object} participant - The locus participant object. + * @returns {Boolean} + */ + isInterpretationSupported: (participant) => { + if (!participant) { + throw new ParameterError( + 'Interpretation support could not be processed, participant is undefined.' + ); + } -/** - * @param {Object} participant - The locus participant object. - * @returns {Boolean} - */ -MemberUtil.isInterpretationSupported = (participant) => { - if (!participant) { - throw new ParameterError( - 'Interpretation support could not be processed, participant is undefined.' - ); - } - - return !participant.doesNotSupportSiInterpreter; -}; + return !participant.doesNotSupportSiInterpreter; + }, + + /** + * @param {Object} participant - The locus participant object. + * @returns {Boolean} + */ + isLiveAnnotationSupported: (participant) => { + if (!participant) { + throw new ParameterError( + 'LiveAnnotation support could not be processed, participant is undefined.' + ); + } -/** - * @param {Object} participant - The locus participant object. - * @returns {Boolean} - */ -MemberUtil.isLiveAnnotationSupported = (participant) => { - if (!participant) { - throw new ParameterError( - 'LiveAnnotation support could not be processed, participant is undefined.' - ); - } - - return !participant.annotatorAssignmentNotAllowed; -}; + return !participant.annotatorAssignmentNotAllowed; + }, + + /** + * utility method for audio/video muted status + * @param {any} participant + * @param {String} statusAccessor + * @param {String} controlsAccessor + * @returns {Boolean | undefined} + */ + isMuted: (participant: any, statusAccessor: string, controlsAccessor: string) => { + // check remote mute + const remoteMute = participant?.controls?.[controlsAccessor]?.muted; + if (remoteMute === true) { + return true; + } -/** - * utility method for audio/video muted status - * @param {any} participant - * @param {String} statusAccessor - * @param {String} controlsAccessor - * @returns {Boolean | undefined} - */ -MemberUtil.isMuted = (participant: any, statusAccessor: string, controlsAccessor: string) => { - // check remote mute - const remoteMute = participant?.controls?.[controlsAccessor]?.muted; - if (remoteMute === true) { - return true; - } - - // check local mute - const localStatus = participant?.status?.[statusAccessor]; - if (localStatus === _RECEIVE_ONLY_) { - return true; - } - if (localStatus === _SEND_RECEIVE_) { - return false; - } + // check local mute + const localStatus = participant?.status?.[statusAccessor]; + if (localStatus === _RECEIVE_ONLY_) { + return true; + } + if (localStatus === _SEND_RECEIVE_) { + return false; + } - return remoteMute; -}; + return remoteMute; + }, + + /** + * utility method for getting the recording member for later comparison + * @param {Object} controls + * @returns {String|null} + */ + getRecordingMember: (controls: any) => { + if (!controls) { + return null; + } + if (controls.record && controls.record.recording && controls.record.meta) { + return controls.record.meta.modifiedBy; + } -/** - * utility method for getting the recording member for later comparison - * @param {Object} controls - * @returns {String|null} - */ -MemberUtil.getRecordingMember = (controls: any) => { - if (!controls) { return null; - } - if (controls.record && controls.record.recording && controls.record.meta) { - return controls.record.meta.modifiedBy; - } + }, + + /** + * @param {Object} participant - The locus participant object. + * @returns {Boolean} + */ + isRecording: (participant: any) => { + if (!participant) { + throw new ParameterError('Recording could not be processed, participant is undefined.'); + } + if (participant.controls && participant.controls.localRecord) { + return participant.controls.localRecord.recording; + } - return null; -}; + return false; + }, -/** - * @param {Object} participant - The locus participant object. - * @returns {Boolean} - */ -MemberUtil.isRecording = (participant: any) => { - if (!participant) { - throw new ParameterError('Recording could not be processed, participant is undefined.'); - } - if (participant.controls && participant.controls.localRecord) { - return participant.controls.localRecord.recording; - } - - return false; -}; + isRemovable: (isSelf, isGuest, isInMeeting, type) => { + if (isGuest || isSelf) { + return false; + } + if (type === _CALL_) { + return false; + } + if (isInMeeting) { + return true; + } -MemberUtil.isRemovable = (isSelf, isGuest, isInMeeting, type) => { - if (isGuest || isSelf) { - return false; - } - if (type === _CALL_) { return false; - } - if (isInMeeting) { - return true; - } + }, - return false; -}; + isMutable: (isSelf, isDevice, isInMeeting, isMuted, type) => { + if (!isInMeeting) { + return false; + } + if (isMuted) { + return false; + } + if (type === _CALL_) { + return false; + } + if (isSelf || isDevice) { + return true; + } -MemberUtil.isMutable = (isSelf, isDevice, isInMeeting, isMuted, type) => { - if (!isInMeeting) { - return false; - } - if (isMuted) { return false; - } - if (type === _CALL_) { - return false; - } - if (isSelf || isDevice) { - return true; - } + }, + + /** + * @param {Object} participant - The locus participant object. + * @returns {String} + */ + extractStatus: (participant: any) => { + if (!(participant && participant.devices && participant.devices.length)) { + return _NOT_IN_MEETING_; + } + if (participant.state === _JOINED_) { + return _IN_MEETING_; + } + if (participant.state === _IDLE_) { + if (participant.devices && participant.devices.length > 0) { + const foundDevice = participant.devices.find( + (device) => + device.intent && (device.intent.type === _WAIT_ || device.intent.type === _OBSERVE_) + ); - return false; -}; + return foundDevice ? _IN_LOBBY_ : _NOT_IN_MEETING_; + } -/** - * @param {Object} participant - The locus participant object. - * @returns {String} - */ -MemberUtil.extractStatus = (participant: any) => { - if (!(participant && participant.devices && participant.devices.length)) { - return _NOT_IN_MEETING_; - } - if (participant.state === _JOINED_) { - return _IN_MEETING_; - } - if (participant.state === _IDLE_) { - if (participant.devices && participant.devices.length > 0) { - const foundDevice = participant.devices.find( - (device) => - device.intent && (device.intent.type === _WAIT_ || device.intent.type === _OBSERVE_) - ); - - return foundDevice ? _IN_LOBBY_ : _NOT_IN_MEETING_; + return _NOT_IN_MEETING_; + } + if (participant.state === _LEFT_) { + return _NOT_IN_MEETING_; } return _NOT_IN_MEETING_; - } - if (participant.state === _LEFT_) { - return _NOT_IN_MEETING_; - } - - return _NOT_IN_MEETING_; -}; - -/** - * @param {Object} participant - The locus participant object. - * @returns {String} - */ -MemberUtil.extractId = (participant: any) => { - if (participant) { - return participant.id; - } - - return null; -}; + }, + + /** + * @param {Object} participant - The locus participant object. + * @returns {String} + */ + extractId: (participant: any) => { + if (participant) { + return participant.id; + } -/** - * extracts the media status from nested participant object - * @param {Object} participant - The locus participant object. - * @returns {Object} - */ -MemberUtil.extractMediaStatus = (participant: any): IMediaStatus => { - if (!participant) { - throw new ParameterError('Media status could not be extracted, participant is undefined.'); - } - - return { - audio: participant.status?.audioStatus, - video: participant.status?.videoStatus, - }; -}; + return null; + }, + + /** + * extracts the media status from nested participant object + * @param {Object} participant - The locus participant object. + * @returns {Object} + */ + extractMediaStatus: (participant: any): IMediaStatus => { + if (!participant) { + throw new ParameterError('Media status could not be extracted, participant is undefined.'); + } -/** - * @param {Object} participant - The locus participant object. - * @returns {String} - */ -MemberUtil.extractName = (participant: any) => { - if (participant && participant.person) { - return participant.person.name; - } + return { + audio: participant.status?.audioStatus, + video: participant.status?.videoStatus, + }; + }, + + /** + * @param {Object} participant - The locus participant object. + * @returns {String} + */ + extractName: (participant: any) => { + if (participant && participant.person) { + return participant.person.name; + } - return null; + return null; + }, }; - export default MemberUtil; diff --git a/packages/@webex/plugin-meetings/src/members/index.ts b/packages/@webex/plugin-meetings/src/members/index.ts index fb8a3cbe52e..9c2d02fa3fc 100644 --- a/packages/@webex/plugin-meetings/src/members/index.ts +++ b/packages/@webex/plugin-meetings/src/members/index.ts @@ -773,6 +773,28 @@ export default class Members extends StatelessWebexPlugin { return this.membersRequest.cancelPhoneInvite(options); } + /** + * Cancels an SIP call to the associated meeting + * @param {String} invitee + * @returns {Promise} + * @memberof Members + */ + cancelSIPInvite(invitee: any) { + if (!this.locusUrl) { + return Promise.reject( + new ParameterError('The associated locus url for this meeting object must be defined.') + ); + } + if (!invitee?.memberId) { + return Promise.reject( + new ParameterError('The invitee must be defined with a memberId property.') + ); + } + const options = MembersUtil.cancelSIPInviteOptions(invitee, this.locusUrl); + + return this.membersRequest.cancelSIPInvite(options); + } + /** * Admits waiting members (invited guests to meeting) * @param {Array} memberIds diff --git a/packages/@webex/plugin-meetings/src/members/request.ts b/packages/@webex/plugin-meetings/src/members/request.ts index b35b0b0bf56..e353f66d5b0 100644 --- a/packages/@webex/plugin-meetings/src/members/request.ts +++ b/packages/@webex/plugin-meetings/src/members/request.ts @@ -278,4 +278,22 @@ export default class MembersRequest extends StatelessWebexPlugin { return this.locusDeltaRequest(requestParams); } + + /** + * @param {Object} options with format of {invitee: object, locusUrl: string} + * @returns {Promise} + * @throws {Error} if the options are not valid and complete, must have invitee with memberId AND locusUrl + * @memberof MembersRequest + */ + cancelSIPInvite(options: any) { + if (!options?.invitee?.memberId || !options?.locusUrl) { + throw new ParameterError( + 'invitee must be passed and the associated locus url for this meeting object must be defined.' + ); + } + + const requestParams = MembersUtil.generateCancelSIPInviteRequestParams(options); + + return this.locusDeltaRequest(requestParams); + } } diff --git a/packages/@webex/plugin-meetings/src/members/util.ts b/packages/@webex/plugin-meetings/src/members/util.ts index 39db0ad4836..19674370b64 100644 --- a/packages/@webex/plugin-meetings/src/members/util.ts +++ b/packages/@webex/plugin-meetings/src/members/util.ts @@ -367,6 +367,29 @@ const MembersUtil = { return requestParams; }, + + cancelSIPInviteOptions: (invitee, locusUrl) => ({ + invitee, + locusUrl, + }), + + generateCancelSIPInviteRequestParams: (options) => { + const body = { + actionType: _REMOVE_, + invitees: [ + { + address: options.invitee.memberId, + }, + ], + }; + const requestParams = { + method: HTTP_VERBS.PUT, + uri: options.locusUrl, + body, + }; + + return requestParams; + }, }; export default MembersUtil; diff --git a/packages/@webex/plugin-meetings/test/unit/spec/breakouts/index.ts b/packages/@webex/plugin-meetings/test/unit/spec/breakouts/index.ts index ac4c2e18321..0c5b3cf2b42 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/breakouts/index.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/breakouts/index.ts @@ -114,39 +114,58 @@ describe('plugin-meetings', () => { assert.notCalled(breakoutClosingHandler); breakouts.set(deps); assert.calledOnce(breakoutClosingHandler); - } - - checkIsCalled({sessionType: BREAKOUTS.SESSION_TYPES.MAIN, groups: undefined, status: undefined}, { - sessionType: BREAKOUTS.SESSION_TYPES.MAIN, - groups: [{status: BREAKOUTS.STATUS.CLOSING}], - status: undefined - }); + }; - checkIsCalled({sessionType: BREAKOUTS.SESSION_TYPES.MAIN, groups: [{status: BREAKOUTS.STATUS.OPEN}], status: undefined}, { - sessionType: BREAKOUTS.SESSION_TYPES.MAIN, - groups: [{status: BREAKOUTS.STATUS.CLOSING}], - status: undefined - }); + checkIsCalled( + {sessionType: BREAKOUTS.SESSION_TYPES.MAIN, groups: undefined, status: undefined}, + { + sessionType: BREAKOUTS.SESSION_TYPES.MAIN, + groups: [{status: BREAKOUTS.STATUS.CLOSING}], + status: undefined, + } + ); - checkIsCalled({sessionType: BREAKOUTS.SESSION_TYPES.BREAKOUT, groups: undefined, status: undefined}, { - sessionType: BREAKOUTS.SESSION_TYPES.BREAKOUT, - groups: undefined, - status: BREAKOUTS.STATUS.CLOSING - }); + checkIsCalled( + { + sessionType: BREAKOUTS.SESSION_TYPES.MAIN, + groups: [{status: BREAKOUTS.STATUS.OPEN}], + status: undefined, + }, + { + sessionType: BREAKOUTS.SESSION_TYPES.MAIN, + groups: [{status: BREAKOUTS.STATUS.CLOSING}], + status: undefined, + } + ); - checkIsCalled({sessionType: BREAKOUTS.SESSION_TYPES.BREAKOUT, groups: undefined, status: BREAKOUTS.STATUS.OPEN}, { - sessionType: BREAKOUTS.SESSION_TYPES.BREAKOUT, - groups: undefined, - status: BREAKOUTS.STATUS.CLOSING - }); + checkIsCalled( + {sessionType: BREAKOUTS.SESSION_TYPES.BREAKOUT, groups: undefined, status: undefined}, + { + sessionType: BREAKOUTS.SESSION_TYPES.BREAKOUT, + groups: undefined, + status: BREAKOUTS.STATUS.CLOSING, + } + ); + checkIsCalled( + { + sessionType: BREAKOUTS.SESSION_TYPES.BREAKOUT, + groups: undefined, + status: BREAKOUTS.STATUS.OPEN, + }, + { + sessionType: BREAKOUTS.SESSION_TYPES.BREAKOUT, + groups: undefined, + status: BREAKOUTS.STATUS.CLOSING, + } + ); }); it('should not emits BREAKOUTS_CLOSING event when just sessionType changed from BREAKOUT to MAIN', () => { breakouts.set({ sessionType: BREAKOUTS.SESSION_TYPES.BREAKOUT, groups: undefined, - status: BREAKOUTS.STATUS.CLOSING + status: BREAKOUTS.STATUS.CLOSING, }); const breakoutClosingHandler = sinon.stub(); @@ -155,7 +174,7 @@ describe('plugin-meetings', () => { breakouts.set({ sessionType: BREAKOUTS.SESSION_TYPES.MAIN, groups: [{status: BREAKOUTS.STATUS.CLOSING}], - status: undefined + status: undefined, }); assert.notCalled(breakoutClosingHandler); @@ -171,14 +190,22 @@ describe('plugin-meetings', () => { it('call triggerReturnToMainEvent correctly when requested breakout add', () => { breakouts.triggerReturnToMainEvent = sinon.stub(); breakouts.breakouts.add({sessionId: 'session1', sessionType: 'MAIN'}); - assert.calledOnceWithExactly(breakouts.triggerReturnToMainEvent, breakouts.breakouts.get('session1')); + assert.calledOnceWithExactly( + breakouts.triggerReturnToMainEvent, + breakouts.breakouts.get('session1') + ); }); it('call triggerReturnToMainEvent correctly when breakout requestedLastModifiedTime change', () => { breakouts.breakouts.add({sessionId: 'session1', sessionType: 'MAIN'}); breakouts.triggerReturnToMainEvent = sinon.stub(); - breakouts.breakouts.get('session1').set({requestedLastModifiedTime: "2023-05-09T17:16:01.000Z"}); - assert.calledOnceWithExactly(breakouts.triggerReturnToMainEvent, breakouts.breakouts.get('session1')); + breakouts.breakouts + .get('session1') + .set({requestedLastModifiedTime: '2023-05-09T17:16:01.000Z'}); + assert.calledOnceWithExactly( + breakouts.triggerReturnToMainEvent, + breakouts.breakouts.get('session1') + ); }); it('call queryPreAssignments correctly when should query preAssignments is true', () => { @@ -195,7 +222,7 @@ describe('plugin-meetings', () => { describe('#listenToCurrentSessionTypeChange', () => { it('triggers leave breakout event when sessionType changed from SESSION to MAIN', () => { const handler = sinon.stub(); - breakouts.currentBreakoutSession.set({sessionType: BREAKOUTS.SESSION_TYPES.BREAKOUT}) + breakouts.currentBreakoutSession.set({sessionType: BREAKOUTS.SESSION_TYPES.BREAKOUT}); breakouts.listenTo(breakouts, BREAKOUTS.EVENTS.LEAVE_BREAKOUT, handler); breakouts.currentBreakoutSession.set({sessionType: BREAKOUTS.SESSION_TYPES.MAIN}); @@ -206,7 +233,7 @@ describe('plugin-meetings', () => { it('should not triggers leave breakout event when sessionType changed from undefined to MAIN', () => { const handler = sinon.stub(); - breakouts.currentBreakoutSession.set({sessionType: undefined}) + breakouts.currentBreakoutSession.set({sessionType: undefined}); breakouts.listenTo(breakouts, BREAKOUTS.EVENTS.LEAVE_BREAKOUT, handler); breakouts.currentBreakoutSession.set({sessionType: BREAKOUTS.SESSION_TYPES.MAIN}); @@ -217,7 +244,7 @@ describe('plugin-meetings', () => { it('should not triggers leave breakout event when sessionType changed from MAIN to SESSION', () => { const handler = sinon.stub(); - breakouts.currentBreakoutSession.set({sessionType: BREAKOUTS.SESSION_TYPES.MAIN}) + breakouts.currentBreakoutSession.set({sessionType: BREAKOUTS.SESSION_TYPES.MAIN}); breakouts.listenTo(breakouts, BREAKOUTS.EVENTS.LEAVE_BREAKOUT, handler); breakouts.currentBreakoutSession.set({sessionType: BREAKOUTS.SESSION_TYPES.BREAKOUT}); @@ -268,7 +295,7 @@ describe('plugin-meetings', () => { callback({ data: { participant: 'participant', - sessionId: 'sessionId' + sessionId: 'sessionId', }, }); @@ -321,38 +348,38 @@ describe('plugin-meetings', () => { it('update the startTime correctly when no attribute startTime exists on params', () => { breakouts.updateBreakout({ - startTime: "startTime" - }) + startTime: 'startTime', + }); assert.equal(breakouts.startTime, 'startTime'); - breakouts.updateBreakout({}) + breakouts.updateBreakout({}); assert.equal(breakouts.startTime, undefined); }); it('update the status correctly when no attribute status exists on params', () => { breakouts.updateBreakout({ - status: 'CLOSING' - }) + status: 'CLOSING', + }); assert.equal(breakouts.status, 'CLOSING'); - breakouts.updateBreakout({}) + breakouts.updateBreakout({}); assert.equal(breakouts.status, undefined); }); it('call clearBreakouts if current breakout is not in-progress', () => { breakouts.clearBreakouts = sinon.stub(); - breakouts.updateBreakout({status: 'CLOSED'}) + breakouts.updateBreakout({status: 'CLOSED'}); assert.calledOnce(breakouts.clearBreakouts); }); it('updates the current breakout session, call onBreakoutJoinResponse when session changed', () => { breakouts.webex.meetings = { getMeetingByType: sinon.stub().returns({ - id: 'meeting-id' - }) + id: 'meeting-id', + }), }; - const onBreakoutJoinResponseSpy = sinon.stub(breakoutEvent,'onBreakoutJoinResponse') - breakouts.currentBreakoutSession.sessionId = "sessionId-old"; + const onBreakoutJoinResponseSpy = sinon.stub(breakoutEvent, 'onBreakoutJoinResponse'); + breakouts.currentBreakoutSession.sessionId = 'sessionId-old'; breakouts.updateBreakout({ sessionId: 'sessionId-new', groupId: 'groupId', @@ -370,19 +397,18 @@ describe('plugin-meetings', () => { assert.calledOnce(onBreakoutJoinResponseSpy); - onBreakoutJoinResponseSpy.restore() - + onBreakoutJoinResponseSpy.restore(); }); it('updates the current breakout session, not call onBreakoutJoinResponse when session no changed', () => { breakouts.webex.meetings = { getMeetingByType: sinon.stub().returns({ - id: 'meeting-id' - }) + id: 'meeting-id', + }), }; const onBreakoutJoinResponseSpy = sinon.stub(breakoutEvent, 'onBreakoutJoinResponse'); - breakouts.currentBreakoutSession.sessionId = "sessionId"; - breakouts.currentBreakoutSession.groupId = "groupId"; + breakouts.currentBreakoutSession.sessionId = 'sessionId'; + breakouts.currentBreakoutSession.groupId = 'groupId'; breakouts.updateBreakout({ sessionId: 'sessionId', groupId: 'groupId', @@ -399,8 +425,7 @@ describe('plugin-meetings', () => { }); assert.notCalled(onBreakoutJoinResponseSpy); - onBreakoutJoinResponseSpy.restore() - + onBreakoutJoinResponseSpy.restore(); }); }); @@ -446,13 +471,16 @@ describe('plugin-meetings', () => { const payload = { breakoutSessions: { assigned: [{sessionId: 'sessionId1'}], - requested: [{sessionId: 'sessionId2', modifiedAt: "2023-05-09T17:16:01.000Z"}], + requested: [{sessionId: 'sessionId2', modifiedAt: '2023-05-09T17:16:01.000Z'}], }, }; breakouts.updateBreakoutSessions(payload); - assert.equal(breakouts.breakouts.get('sessionId1').requestedLastModifiedTime, undefined) - assert.equal(breakouts.breakouts.get('sessionId2').requestedLastModifiedTime, "2023-05-09T17:16:01.000Z") + assert.equal(breakouts.breakouts.get('sessionId1').requestedLastModifiedTime, undefined); + assert.equal( + breakouts.breakouts.get('sessionId2').requestedLastModifiedTime, + '2023-05-09T17:16:01.000Z' + ); }); it('not update breakout sessions when breakouts is closing', () => { @@ -603,15 +631,15 @@ describe('plugin-meetings', () => { describe('#breakoutStatus', () => { it('return status from groups with session type', () => { - breakouts.set('groups', [{status: "OPEN"}]); - breakouts.set('status', "CLOSED"); + breakouts.set('groups', [{status: 'OPEN'}]); + breakouts.set('status', 'CLOSED'); breakouts.set('sessionType', BREAKOUTS.SESSION_TYPES.MAIN); - assert.equal(breakouts.breakoutStatus, "OPEN") + assert.equal(breakouts.breakoutStatus, 'OPEN'); breakouts.set('sessionType', BREAKOUTS.SESSION_TYPES.BREAKOUT); - assert.equal(breakouts.breakoutStatus, "CLOSED") + assert.equal(breakouts.breakoutStatus, 'CLOSED'); }); }); @@ -634,7 +662,7 @@ describe('plugin-meetings', () => { it('return breakout is in progress depends on the status(groups/breakouts)', () => { breakouts.set('groups', [{status: 'CLOSING'}]); - assert.equal(breakouts.isBreakoutInProgress(), true) + assert.equal(breakouts.isBreakoutInProgress(), true); breakouts.set('groups', undefined); breakouts.set('status', 'OPEN'); @@ -1107,7 +1135,7 @@ describe('plugin-meetings', () => { someOtherParam: 'someOtherParam', }); assert.deepEqual(result, {body: mockedReturnBody}); - assert.calledWithExactly(breakouts._setManageGroups, {body: mockedReturnBody}) + assert.calledWithExactly(breakouts._setManageGroups, {body: mockedReturnBody}); }); it('rejects when edit lock token mismatch', async () => { @@ -1668,29 +1696,29 @@ describe('plugin-meetings', () => { describe('#queryPreAssignments', () => { it('makes the expected query', async () => { const mockPreAssignments = [ - { - sessions: [ - { - name: 'Breakout session 1', - assignedEmails: ['aa@aa.com', 'bb@bb.com', 'cc@cc.com'], - anyoneCanJoin: false, - }, - { - name: 'Breakout session 2', - anyoneCanJoin: false, - }, - { - name: 'Breakout session 3', - assignedEmails: ['cc@cc.com'], - anyoneCanJoin: false, - }, - ], - unassignedInvitees: { - emails: ['dd@dd.com'], + { + sessions: [ + { + name: 'Breakout session 1', + assignedEmails: ['aa@aa.com', 'bb@bb.com', 'cc@cc.com'], + anyoneCanJoin: false, }, - type: 'BREAKOUT', + { + name: 'Breakout session 2', + anyoneCanJoin: false, + }, + { + name: 'Breakout session 3', + assignedEmails: ['cc@cc.com'], + anyoneCanJoin: false, + }, + ], + unassignedInvitees: { + emails: ['dd@dd.com'], }, - ]; + type: 'BREAKOUT', + }, + ]; webex.request.returns( Promise.resolve({ body: { @@ -1705,7 +1733,7 @@ describe('plugin-meetings', () => { uri: 'url/preassignments', qs: { locusUrl: 'dGVzdA==', - } + }, }); assert.deepEqual(breakouts.preAssignments, mockPreAssignments); @@ -1721,7 +1749,10 @@ describe('plugin-meetings', () => { }; webex.request.rejects(response); LoggerProxy.logger.error = sinon.stub(); - const result = await breakouts.queryPreAssignments({enableBreakoutSession: true, hasBreakoutPreAssignments: true}); + const result = await breakouts.queryPreAssignments({ + enableBreakoutSession: true, + hasBreakoutPreAssignments: true, + }); await testUtils.flushPromises(); assert.calledOnceWithExactly( LoggerProxy.logger.error, @@ -1730,20 +1761,35 @@ describe('plugin-meetings', () => { ); }); - it('fail when no correct params', () => { - + it('fail when no correct params', () => { assert.deepEqual(breakouts.queryPreAssignments(undefined), undefined); assert.deepEqual(breakouts.queryPreAssignments({}), undefined); - assert.deepEqual(breakouts.queryPreAssignments({ enableBreakoutSession: true, hasBreakoutPreAssignments: false }), undefined); - - assert.deepEqual(breakouts.queryPreAssignments({ enableBreakoutSession: false, hasBreakoutPreAssignments: true }), undefined); + assert.deepEqual( + breakouts.queryPreAssignments({ + enableBreakoutSession: true, + hasBreakoutPreAssignments: false, + }), + undefined + ); - assert.deepEqual(breakouts.queryPreAssignments({ enableBreakoutSession: false, hasBreakoutPreAssignments: false }), undefined); + assert.deepEqual( + breakouts.queryPreAssignments({ + enableBreakoutSession: false, + hasBreakoutPreAssignments: true, + }), + undefined + ); + assert.deepEqual( + breakouts.queryPreAssignments({ + enableBreakoutSession: false, + hasBreakoutPreAssignments: false, + }), + undefined + ); }); - }); describe('#dynamicAssign', () => { @@ -1775,6 +1821,32 @@ describe('plugin-meetings', () => { }); }); + describe('#moveToLobby', () => { + it('should make a PUT request with correct body and return the result', async () => { + breakouts.moveToLobby = sinon.stub().returns(Promise.resolve('REQUEST_RETURN_VALUE')); + + const expectedBody = { + groups: [ + { + id: 'mainGroupId', + sessions: [ + { + id: 'mainSessionId', + participants: ['participant1'], + targetState: 'LOBBY', + }, + ], + }, + ], + }; + + const result = await breakouts.moveToLobby(expectedBody); + + assert.calledOnceWithExactly(breakouts.moveToLobby, expectedBody); + assert.equal(result, 'REQUEST_RETURN_VALUE'); + }); + }); + describe('#triggerReturnToMainEvent', () => { const checkTrigger = ({breakout, shouldTrigger}) => { breakouts.trigger = sinon.stub(); @@ -1784,19 +1856,19 @@ describe('plugin-meetings', () => { } else { assert.notCalled(breakouts.trigger); } - } + }; it('should trigger ASK_RETURN_TO_MAIN event correctly', () => { const breakout = { isMain: true, - requested: true + requested: true, }; - checkTrigger({breakout, shouldTrigger: true}) + checkTrigger({breakout, shouldTrigger: true}); }); it('should not trigger ASK_RETURN_TO_MAIN event when sessionType is not MAIN', () => { const breakout = { isMain: false, - requested: true + requested: true, }; checkTrigger({breakout, shouldTrigger: false}); }); @@ -1804,9 +1876,9 @@ describe('plugin-meetings', () => { it('should not trigger ASK_RETURN_TO_MAIN event when session is not requested', () => { const breakout = { isMain: true, - requested: false + requested: false, }; - checkTrigger({breakout, shouldTrigger: false}) + checkTrigger({breakout, shouldTrigger: false}); }); }); }); diff --git a/packages/@webex/plugin-meetings/test/unit/spec/locus-info/index.js b/packages/@webex/plugin-meetings/test/unit/spec/locus-info/index.js index 381722204c0..d780c226eae 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/locus-info/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/locus-info/index.js @@ -825,6 +825,32 @@ describe('plugin-meetings', () => { assert.isTrue(locusInfo.deltaParticipants.length === 0); }); + + it('should call with participant display name', () => { + const failureParticipant = [ + { + person: { + id: 5678, + primaryDisplayString: 'Test User', + }, + reason: 'FAILURE', + }, + ]; + + locusInfo.emitScoped = sinon.stub(); + locusInfo.updateParticipants(failureParticipant); + assert.calledWith( + locusInfo.emitScoped, + { + file: 'locus-info', + function: 'updateParticipants', + }, + LOCUSINFO.EVENTS.PARTICIPANT_REASON_CHANGED, + { + displayName: 'Test User', + } + ); + }) }); describe('#updateSelf', () => { @@ -836,7 +862,7 @@ describe('plugin-meetings', () => { selfWithBrbChanged.controls.brb = enabled; locusInfo.emitScoped = sinon.stub(); - locusInfo.updateSelf(selfWithBrbChanged, []); + locusInfo.updateSelf(selfWithBrbChanged); assert.calledWith( locusInfo.emitScoped, @@ -856,14 +882,15 @@ describe('plugin-meetings', () => { const selfWithBrbChanged = cloneDeep(self); selfWithBrbChanged.controls.brb = value; - locusInfo.self = selfWithBrbChanged; + + locusInfo.updateSelf(selfWithBrbChanged); locusInfo.emitScoped = sinon.stub(); const newSelf = cloneDeep(self); newSelf.controls.brb = value; - locusInfo.updateSelf(newSelf, []); + locusInfo.updateSelf(newSelf); assert.neverCalledWith( locusInfo.emitScoped, @@ -880,14 +907,14 @@ describe('plugin-meetings', () => { it('should not trigger SELF_MEETING_BRB_CHANGED when brb state is undefined', () => { const selfWithBrbChanged = cloneDeep(self); selfWithBrbChanged.controls.brb = false; - locusInfo.self = selfWithBrbChanged; + locusInfo.updateSelf(selfWithBrbChanged); locusInfo.emitScoped = sinon.stub(); const newSelf = cloneDeep(self); newSelf.controls.brb = undefined; - locusInfo.updateSelf(newSelf, []); + locusInfo.updateSelf(newSelf); assert.neverCalledWith( locusInfo.emitScoped, @@ -910,7 +937,7 @@ describe('plugin-meetings', () => { ]; locusInfo.emitScoped = sinon.stub(); - locusInfo.updateSelf(selfWithLayoutChanged, []); + locusInfo.updateSelf(selfWithLayoutChanged); assert.calledWith( locusInfo.emitScoped, @@ -936,11 +963,11 @@ describe('plugin-meetings', () => { ]; // Set the layout prior to stubbing to validate it does not change. - locusInfo.updateSelf(selfWithLayoutChanged, []); + locusInfo.updateSelf(selfWithLayoutChanged); locusInfo.emitScoped = sinon.stub(); - locusInfo.updateSelf(selfWithLayoutChanged, []); + locusInfo.updateSelf(selfWithLayoutChanged); assert.neverCalledWith( locusInfo.emitScoped, @@ -954,11 +981,11 @@ describe('plugin-meetings', () => { }); it('should trigger MEDIA_INACTIVITY on server media inactivity', () => { - locusInfo.self = self; - locusInfo.webex.internal.device.url = selfWithInactivity.deviceUrl; + locusInfo.updateSelf(self); + locusInfo.emitScoped = sinon.stub(); - locusInfo.updateSelf(selfWithInactivity, []); + locusInfo.updateSelf(selfWithInactivity); assert.calledWith( locusInfo.emitScoped, @@ -980,7 +1007,7 @@ describe('plugin-meetings', () => { locusInfo.webex.internal.device.url = self.deviceUrl; locusInfo.emitScoped = sinon.stub(); - locusInfo.updateSelf(selfWithMutedByOthers, []); + locusInfo.updateSelf(selfWithMutedByOthers); assert.calledWith( locusInfo.emitScoped, @@ -993,10 +1020,10 @@ describe('plugin-meetings', () => { ); // but sometimes "previous self" is defined, but without controls.audio.muted, so we test this here: - locusInfo.self = cloneDeep(self); + locusInfo.updateSelf(self); locusInfo.self.controls.audio = {}; - locusInfo.updateSelf(selfWithMutedByOthers, []); + locusInfo.updateSelf(selfWithMutedByOthers); assert.calledWith( locusInfo.emitScoped, { @@ -1016,7 +1043,7 @@ describe('plugin-meetings', () => { locusInfo.webex.internal.device.url = self.deviceUrl; locusInfo.emitScoped = sinon.stub(); - locusInfo.updateSelf(selfWithMutedByOthersFalse, []); + locusInfo.updateSelf(selfWithMutedByOthersFalse); // we might get some calls to emitScoped, but we need to check that none of them are for SELF_REMOTE_MUTE_STATUS_UPDATED locusInfo.emitScoped.getCalls().forEach((x) => { @@ -1025,20 +1052,20 @@ describe('plugin-meetings', () => { }); it('should not trigger SELF_REMOTE_MUTE_STATUS_UPDATED when being removed from meeting', () => { + locusInfo.webex.internal.device.url = self.deviceUrl; const selfWithMutedByOthers = cloneDeep(self); selfWithMutedByOthers.controls.audio.muted = true; - locusInfo.self = selfWithMutedByOthers; + locusInfo.updateSelf(selfWithMutedByOthers); // when user gets removed from meeting we receive a Locus DTO without any self.controls const selfWithoutControls = cloneDeep(self); selfWithoutControls.controls = undefined; - locusInfo.webex.internal.device.url = self.deviceUrl; locusInfo.emitScoped = sinon.stub(); - locusInfo.updateSelf(selfWithoutControls, []); + locusInfo.updateSelf(selfWithoutControls); // we might get some calls to emitScoped, but we need to check that none of them are for SELF_REMOTE_MUTE_STATUS_UPDATED locusInfo.emitScoped.getCalls().forEach((x) => { @@ -1047,14 +1074,14 @@ describe('plugin-meetings', () => { }); it('should trigger SELF_REMOTE_MUTE_STATUS_UPDATED on othersMuted', () => { - locusInfo.self = self; + locusInfo.webex.internal.device.url = self.deviceUrl; + locusInfo.updateSelf(self); const selfWithMutedByOthers = cloneDeep(self); selfWithMutedByOthers.controls.audio.muted = true; - locusInfo.webex.internal.device.url = self.deviceUrl; locusInfo.emitScoped = sinon.stub(); - locusInfo.updateSelf(selfWithMutedByOthers, []); + locusInfo.updateSelf(selfWithMutedByOthers); assert.calledWith( locusInfo.emitScoped, @@ -1078,7 +1105,7 @@ describe('plugin-meetings', () => { locusInfo.webex.internal.device.url = self.deviceUrl; locusInfo.emitScoped = sinon.stub(); - locusInfo.updateSelf(selfWithMutedByOthers, []); + locusInfo.updateSelf(selfWithMutedByOthers); assert.calledWith( locusInfo.emitScoped, @@ -1091,10 +1118,10 @@ describe('plugin-meetings', () => { ); // but sometimes "previous self" is defined, but without controls.audio.muted, so we test this here: - locusInfo.self = cloneDeep(self); + locusInfo.updateSelf(self); locusInfo.self.controls.video = {}; - locusInfo.updateSelf(selfWithMutedByOthers, []); + locusInfo.updateSelf(selfWithMutedByOthers); assert.calledWith( locusInfo.emitScoped, { @@ -1114,7 +1141,7 @@ describe('plugin-meetings', () => { locusInfo.webex.internal.device.url = self.deviceUrl; locusInfo.emitScoped = sinon.stub(); - locusInfo.updateSelf(selfWithMutedByOthersFalse, []); + locusInfo.updateSelf(selfWithMutedByOthersFalse); // we might get some calls to emitScoped, but we need to check that none of them are for SELF_REMOTE_VIDEO_MUTE_STATUS_UPDATED locusInfo.emitScoped.getCalls().forEach((x) => { @@ -1123,14 +1150,14 @@ describe('plugin-meetings', () => { }); it('should emit event when remoteVideoMuted changed', () => { - locusInfo.self = self; + locusInfo.webex.internal.device.url = self.deviceUrl; + locusInfo.updateSelf(self); const selfWithMutedByOthers = cloneDeep(self); selfWithMutedByOthers.controls.video.muted = true; - locusInfo.webex.internal.device.url = self.deviceUrl; locusInfo.emitScoped = sinon.stub(); - locusInfo.updateSelf(selfWithMutedByOthers, []); + locusInfo.updateSelf(selfWithMutedByOthers); assert.calledWith( locusInfo.emitScoped, @@ -1145,13 +1172,13 @@ describe('plugin-meetings', () => { }); it('should trigger SELF_MEETING_BREAKOUTS_CHANGED when breakouts changed', () => { - locusInfo.self = self; + locusInfo.updateSelf(self); const selfWithBreakoutsChanged = cloneDeep(self); selfWithBreakoutsChanged.controls.breakout.sessions.active[0].name = 'new name'; locusInfo.emitScoped = sinon.stub(); - locusInfo.updateSelf(selfWithBreakoutsChanged, []); + locusInfo.updateSelf(selfWithBreakoutsChanged); assert.calledWith( locusInfo.emitScoped, @@ -1184,16 +1211,16 @@ describe('plugin-meetings', () => { }); it('should trigger SELF_REMOTE_MUTE_STATUS_UPDATED if muted and disallowUnmute changed', () => { - locusInfo.self = self; + locusInfo.webex.internal.device.url = self.deviceUrl; + locusInfo.updateSelf(self); const selfWithMutedByOthersAndDissalowUnmute = cloneDeep(self); // first simulate remote mute selfWithMutedByOthersAndDissalowUnmute.controls.audio.muted = true; selfWithMutedByOthersAndDissalowUnmute.controls.audio.disallowUnmute = true; - locusInfo.webex.internal.device.url = self.deviceUrl; locusInfo.emitScoped = sinon.stub(); - locusInfo.updateSelf(selfWithMutedByOthersAndDissalowUnmute, []); + locusInfo.updateSelf(selfWithMutedByOthersAndDissalowUnmute); assert.calledWith( locusInfo.emitScoped, @@ -1211,7 +1238,7 @@ describe('plugin-meetings', () => { selfWithMutedByOthers.controls.audio.muted = true; selfWithMutedByOthers.controls.audio.disallowUnmute = false; - locusInfo.updateSelf(selfWithMutedByOthers, []); + locusInfo.updateSelf(selfWithMutedByOthers); assert.calledWith( locusInfo.emitScoped, @@ -1225,15 +1252,15 @@ describe('plugin-meetings', () => { }); it('should trigger LOCAL_UNMUTE_REQUIRED on localAudioUnmuteRequired', () => { - locusInfo.self = self; + locusInfo.webex.internal.device.url = self.deviceUrl; + locusInfo.updateSelf(self); const selfWithLocalUnmuteRequired = cloneDeep(self); selfWithLocalUnmuteRequired.controls.audio.muted = false; selfWithLocalUnmuteRequired.controls.audio.localAudioUnmuteRequired = true; - locusInfo.webex.internal.device.url = self.deviceUrl; locusInfo.emitScoped = sinon.stub(); - locusInfo.updateSelf(selfWithLocalUnmuteRequired, []); + locusInfo.updateSelf(selfWithLocalUnmuteRequired); assert.calledWith( locusInfo.emitScoped, @@ -1250,16 +1277,16 @@ describe('plugin-meetings', () => { }); it('should trigger LOCAL_UNMUTE_REQUESTED when receiving requestedToUnmute=true', () => { - locusInfo.self = self; + locusInfo.webex.internal.device.url = self.deviceUrl; + locusInfo.updateSelf(self); const selfWithRequestedToUnmute = cloneDeep(self); selfWithRequestedToUnmute.controls.audio.requestedToUnmute = true; selfWithRequestedToUnmute.controls.audio.lastModifiedRequestedToUnmute = '2023-06-16T19:25:04.369Z'; - locusInfo.webex.internal.device.url = self.deviceUrl; locusInfo.emitScoped = sinon.stub(); - locusInfo.updateSelf(selfWithRequestedToUnmute, []); + locusInfo.updateSelf(selfWithRequestedToUnmute); assert.calledWith( locusInfo.emitScoped, @@ -1277,7 +1304,7 @@ describe('plugin-meetings', () => { selfWithoutRequestedToUnmute.controls.audio.requestedToUnmute = false; locusInfo.emitScoped.resetHistory(); - locusInfo.updateSelf(selfWithoutRequestedToUnmute, []); + locusInfo.updateSelf(selfWithoutRequestedToUnmute); assert.neverCalledWith( locusInfo.emitScoped, @@ -1291,15 +1318,14 @@ describe('plugin-meetings', () => { }); it('should trigger SELF_OBSERVING when moving meeting to DX', () => { - locusInfo.self = self; + locusInfo.webex.internal.device.url = self.deviceUrl; + locusInfo.updateSelf(self); const selfInitiatedMove = cloneDeep(self); // Inital move meeting is iniated selfInitiatedMove.devices[0].intent.type = 'MOVE_MEDIA'; - locusInfo.webex.internal.device.url = self.deviceUrl; - - locusInfo.updateSelf(selfInitiatedMove, []); + locusInfo.updateSelf(selfInitiatedMove); locusInfo.emitScoped = sinon.stub(); // When dx joined the meeting after move @@ -1307,7 +1333,7 @@ describe('plugin-meetings', () => { selfAfterDxJoins.devices[0].intent.type = 'OBSERVE'; - locusInfo.updateSelf(selfAfterDxJoins, []); + locusInfo.updateSelf(selfAfterDxJoins); assert.calledWith( locusInfo.emitScoped, @@ -1325,11 +1351,11 @@ describe('plugin-meetings', () => { selfClone.canNotViewTheParticipantList = false; // same // Set the layout prior to stubbing to validate it does not change. - locusInfo.updateSelf(self, []); + locusInfo.updateSelf(self); locusInfo.emitScoped = sinon.stub(); - locusInfo.updateSelf(selfClone, []); + locusInfo.updateSelf(selfClone); assert.neverCalledWith( locusInfo.emitScoped, @@ -1348,11 +1374,11 @@ describe('plugin-meetings', () => { selfClone.canNotViewTheParticipantList = true; // different // Set the layout prior to stubbing to validate it does not change. - locusInfo.updateSelf(self, []); + locusInfo.updateSelf(self); locusInfo.emitScoped = sinon.stub(); - locusInfo.updateSelf(selfClone, []); + locusInfo.updateSelf(selfClone); assert.calledWith( locusInfo.emitScoped, @@ -1371,11 +1397,11 @@ describe('plugin-meetings', () => { selfClone.isSharingBlocked = false; // same // Set the layout prior to stubbing to validate it does not change. - locusInfo.updateSelf(self, []); + locusInfo.updateSelf(self); locusInfo.emitScoped = sinon.stub(); - locusInfo.updateSelf(selfClone, []); + locusInfo.updateSelf(selfClone); assert.neverCalledWith( locusInfo.emitScoped, @@ -1394,11 +1420,11 @@ describe('plugin-meetings', () => { selfClone.isSharingBlocked = true; // different // Set the layout prior to stubbing to validate it does not change. - locusInfo.updateSelf(self, []); + locusInfo.updateSelf(self); locusInfo.emitScoped = sinon.stub(); - locusInfo.updateSelf(selfClone, []); + locusInfo.updateSelf(selfClone); assert.calledWith( locusInfo.emitScoped, @@ -1412,12 +1438,12 @@ describe('plugin-meetings', () => { }); it('should trigger SELF_ROLES_CHANGED if self roles changed', () => { - locusInfo.self = self; + locusInfo.updateSelf(self); locusInfo.emitScoped = sinon.stub(); const sampleNewSelf = cloneDeep(self); sampleNewSelf.controls.role.roles = [{type: 'COHOST', hasRole: true}]; - locusInfo.updateSelf(sampleNewSelf, []); + locusInfo.updateSelf(sampleNewSelf); assert.calledWith( locusInfo.emitScoped, @@ -1431,12 +1457,12 @@ describe('plugin-meetings', () => { }); it('should not trigger SELF_ROLES_CHANGED if self roles not changed', () => { - locusInfo.self = self; + locusInfo.updateSelf(self); locusInfo.emitScoped = sinon.stub(); const sampleNewSelf = cloneDeep(self); sampleNewSelf.controls.role.roles = [{type: 'PRESENTER', hasRole: true}]; - locusInfo.updateSelf(sampleNewSelf, []); + locusInfo.updateSelf(sampleNewSelf); assert.neverCalledWith( locusInfo.emitScoped, @@ -1450,12 +1476,12 @@ describe('plugin-meetings', () => { }); it('should trigger SELF_MEETING_INTERPRETATION_CHANGED if self interpretation info changed', () => { - locusInfo.self = self; + locusInfo.updateSelf(self); locusInfo.emitScoped = sinon.stub(); const sampleNewSelf = cloneDeep(self); sampleNewSelf.controls.interpretation.targetLanguage = 'it'; - locusInfo.updateSelf(sampleNewSelf, []); + locusInfo.updateSelf(sampleNewSelf); assert.calledWith( locusInfo.emitScoped, @@ -1472,12 +1498,12 @@ describe('plugin-meetings', () => { }); it('should not trigger SELF_MEETING_INTERPRETATION_CHANGED if self interpretation info not changed', () => { - locusInfo.self = self; + locusInfo.updateSelf(self); locusInfo.emitScoped = sinon.stub(); const sampleNewSelf = cloneDeep(self); sampleNewSelf.controls.interpretation.targetLanguage = 'cn'; // same with previous one - locusInfo.updateSelf(sampleNewSelf, []); + locusInfo.updateSelf(sampleNewSelf); assert.neverCalledWith( locusInfo.emitScoped, @@ -1494,12 +1520,12 @@ describe('plugin-meetings', () => { }); it('should not trigger any events if controls is undefined', () => { - locusInfo.self = self; + locusInfo.updateSelf(self); locusInfo.emitScoped = sinon.stub(); const newSelf = cloneDeep(self); newSelf.controls = undefined; - locusInfo.updateSelf(newSelf, []); + locusInfo.updateSelf(newSelf); const eventsSet = new Set([ LOCUSINFO.EVENTS.CONTROLS_MEETING_LAYOUT_UPDATED, @@ -1516,6 +1542,31 @@ describe('plugin-meetings', () => { assert.isFalse(eventsSet.has(eventName)); }); }); + + it('calls getSelves with correct parameters', () => { + const getSelvesStub = sinon.stub(SelfUtils, 'getSelves').returns({ + current: {}, + previous: {}, + updates: {}, + }); + + locusInfo.webex.internal.device.url = self.deviceUrl; + locusInfo.participants = [{id: '1'}, {id: '2'}]; + locusInfo.parsedLocus.self = {id: 'fake parsed locus self id'}; + + const parsedLocusSelf = locusInfo.parsedLocus.self; // need to store it before it's updated in updateSelf + locusInfo.updateSelf(self); + + assert.calledWith( + getSelvesStub, + parsedLocusSelf, + self, + locusInfo.webex.internal.device.url, + locusInfo.participants + ); + + getSelvesStub.restore(); + }); }); describe('#updateMeetingInfo', () => { @@ -1782,11 +1833,11 @@ describe('plugin-meetings', () => { }); it('should update media shares and emit LOCUS_INFO_UPDATE_MEDIA_SHARES when mediaShares change', () => { - const initialMediaShares = { audio: true, video: false }; - const newMediaShares = { audio: false, video: true }; + const initialMediaShares = {audio: true, video: false}; + const newMediaShares = {audio: false, video: true}; locusInfo.mediaShares = initialMediaShares; - locusInfo.parsedLocus = { mediaShares: null }; + locusInfo.parsedLocus = {mediaShares: null}; const parsedMediaShares = { current: newMediaShares, @@ -1823,9 +1874,9 @@ describe('plugin-meetings', () => { }); it('should force update media shares and emit LOCUS_INFO_UPDATE_MEDIA_SHARES even if shares are the same', () => { - const initialMediaShares = { audio: true, video: false }; + const initialMediaShares = {audio: true, video: false}; locusInfo.mediaShares = initialMediaShares; - locusInfo.parsedLocus = { mediaShares: null }; + locusInfo.parsedLocus = {mediaShares: null}; const parsedMediaShares = { current: initialMediaShares, @@ -1857,7 +1908,7 @@ describe('plugin-meetings', () => { }); it('should not emit LOCUS_INFO_UPDATE_MEDIA_SHARES if mediaShares do not change and forceUpdate is false', () => { - const initialMediaShares = { audio: true, video: false }; + const initialMediaShares = {audio: true, video: false}; locusInfo.mediaShares = initialMediaShares; // Call the function with the same mediaShares and forceUpdate = false @@ -1871,11 +1922,11 @@ describe('plugin-meetings', () => { }); it('should update internal state correctly when mediaShares are updated', () => { - const initialMediaShares = { audio: true, video: false }; - const newMediaShares = { audio: false, video: true }; + const initialMediaShares = {audio: true, video: false}; + const newMediaShares = {audio: false, video: true}; locusInfo.mediaShares = initialMediaShares; - locusInfo.parsedLocus = { mediaShares: null }; + locusInfo.parsedLocus = {mediaShares: null}; const parsedMediaShares = { current: newMediaShares, @@ -2414,6 +2465,21 @@ describe('plugin-meetings', () => { locusInfo.onDeltaLocus(fakeLocus); assert.calledWith(locusInfo.updateParticipants, {}, true); }); + + it('onDeltaLocus merges delta participants with existing participants', () => { + const FAKE_DELTA_PARTICIPANTS = [ + {id: '1111'}, {id: '2222'} + ] + fakeLocus.participants = FAKE_DELTA_PARTICIPANTS; + + sinon.spy(locusInfo, 'mergeParticipants'); + locusInfo.updateParticipants = sinon.stub(); + const existingParticipants = locusInfo.participants; + + locusInfo.onDeltaLocus(fakeLocus); + assert.calledOnceWithExactly(locusInfo.mergeParticipants, existingParticipants, FAKE_DELTA_PARTICIPANTS); + assert.calledWith(locusInfo.updateParticipants, FAKE_DELTA_PARTICIPANTS, false); + }); }); describe('#updateLocusCache', () => { diff --git a/packages/@webex/plugin-meetings/test/unit/spec/locus-info/selfUtils.js b/packages/@webex/plugin-meetings/test/unit/spec/locus-info/selfUtils.js index e1a3588a726..53e9bdf98d9 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/locus-info/selfUtils.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/locus-info/selfUtils.js @@ -4,7 +4,7 @@ import {cloneDeep, defaultsDeep} from 'lodash'; import SelfUtils from '@webex/plugin-meetings/src/locus-info/selfUtils'; import {self} from './selfConstant'; -import {_IDLE_, _WAIT_} from '@webex/plugin-meetings/src/constants'; +import {_IDLE_, _WAIT_, _OBSERVE_, _NONE_} from '@webex/plugin-meetings/src/constants'; describe('plugin-meetings', () => { describe('selfUtils', () => { @@ -269,13 +269,18 @@ describe('plugin-meetings', () => { }); describe('getSelves', () => { + let parsedSelf; + + beforeEach(() => { + parsedSelf = SelfUtils.parse(self); + }); describe('canNotViewTheParticipantListChanged', () => { it('should return canNotViewTheParticipantListChanged = true when changed', () => { const clonedSelf = cloneDeep(self); clonedSelf.canNotViewTheParticipantList = true; // different - const {updates} = SelfUtils.getSelves(self, clonedSelf); + const {updates} = SelfUtils.getSelves(parsedSelf, clonedSelf); assert.equal(updates.canNotViewTheParticipantListChanged, true); }); @@ -285,7 +290,7 @@ describe('plugin-meetings', () => { clonedSelf.canNotViewTheParticipantList = false; // same - const {updates} = SelfUtils.getSelves(self, clonedSelf); + const {updates} = SelfUtils.getSelves(parsedSelf, clonedSelf); assert.equal(updates.canNotViewTheParticipantListChanged, false); }); @@ -295,7 +300,7 @@ describe('plugin-meetings', () => { it('should return localAudioUnmuteRequestedByServer = false when requestedToUnmute = false', () => { const clonedSelf = cloneDeep(self); - const {updates} = SelfUtils.getSelves(self, clonedSelf); + const {updates} = SelfUtils.getSelves(parsedSelf, clonedSelf); assert.equal(updates.localAudioUnmuteRequestedByServer, false); }); @@ -307,7 +312,7 @@ describe('plugin-meetings', () => { clonedSelf.controls.audio.requestedToUnmute = true; clonedSelf.controls.audio.lastModifiedRequestedToUnmute = '2023-06-16T18:25:04.369Z'; - const {updates} = SelfUtils.getSelves(self, clonedSelf); + const {updates} = SelfUtils.getSelves(parsedSelf, clonedSelf); assert.equal(updates.localAudioUnmuteRequestedByServer, true); }); @@ -321,7 +326,7 @@ describe('plugin-meetings', () => { clonedSelf.controls.audio.requestedToUnmute = true; clonedSelf.controls.audio.lastModifiedRequestedToUnmute = '2023-06-16T19:25:04.369Z'; - const {updates} = SelfUtils.getSelves(self, clonedSelf); + const {updates} = SelfUtils.getSelves(parsedSelf, clonedSelf); assert.equal(updates.localAudioUnmuteRequestedByServer, true); }); @@ -334,70 +339,139 @@ describe('plugin-meetings', () => { clonedSelf.controls.audio.requestedToUnmute = true; clonedSelf.controls.audio.lastModifiedRequestedToUnmute = '2023-06-16T18:25:04.369Z'; - const {updates} = SelfUtils.getSelves(self, clonedSelf); + const {updates} = SelfUtils.getSelves(parsedSelf, clonedSelf); assert.equal(updates.localAudioUnmuteRequestedByServer, false); }); }); - describe('updates.isUserUnadmitted', () => { - const testIsUserUnadmitted = (previousObjectDelta, currentObjectDelta, expected) => function () { - const previous = - previousObjectDelta === undefined ? undefined : defaultsDeep(previousObjectDelta, self); - const current = defaultsDeep(currentObjectDelta, self); - - const {updates} = SelfUtils.getSelves(previous, current, self.devices[0].url); - - assert.equal(updates.isUserUnadmitted, expected); - }; + describe('updates.hasUserEnteredLobby', () => { + const testIsUserUnadmitted = ( + previousParsedSelves, + currentSelfDelta, + participants, + expected + ) => + function () { + const currentSelf = defaultsDeep(currentSelfDelta, self); + + if (previousParsedSelves === undefined) { + parsedSelf.state = undefined; + } else { + parsedSelf = defaultsDeep(previousParsedSelves, parsedSelf); + } + const {updates} = SelfUtils.getSelves( + parsedSelf, + currentSelf, + currentSelf.devices[0].url, + participants + ); + + assert.equal(updates.hasUserEnteredLobby, expected); + }; it( 'should return true when previous is undefined and current is in lobby', testIsUserUnadmitted( undefined, {devices: [{intent: {type: _WAIT_}}], state: _IDLE_}, + [], true ) ); it( 'should return false when previous is undefined and user is not in meeting', - testIsUserUnadmitted(undefined, {devices: [], state: _IDLE_}, false) + testIsUserUnadmitted(undefined, {devices: [], state: _IDLE_}, [], false) ); it( 'should return false when previous is undefined and current is in meeting', - testIsUserUnadmitted(undefined, {}, false) + testIsUserUnadmitted(undefined, {}, [], false) + ); + + it( + 'should return true when previous is undefined and current is in lobby with paired device', + testIsUserUnadmitted( + undefined, + { + devices: [{intent: {type: _OBSERVE_, associatedWith: 'pairedDeviceUrl'}}], + state: _IDLE_, + }, + [{url: 'pairedDeviceUrl', devices: [{intent: {type: _WAIT_}}]}], + true + ) + ); + + it( + 'should return false when previous is in lobby with paired device and current is the same', + testIsUserUnadmitted( + { + pairedWith: {intent: {type: _WAIT_}}, + joinedWith: {intent: {type: _OBSERVE_}}, + state: _IDLE_, + }, + { + devices: [{intent: {type: _OBSERVE_, associatedWith: 'pairedDeviceUrl'}}], + state: _IDLE_, + }, + [{url: 'pairedDeviceUrl', devices: [{intent: {type: _WAIT_}}]}], + false + ) ); it( 'should return false when previous is in lobby and current is in lobby', testIsUserUnadmitted( + {joinedWith: {intent: {type: _WAIT_}}, state: _IDLE_}, {devices: [{intent: {type: _WAIT_}}], state: _IDLE_}, - {devices: [{intent: {type: _WAIT_}}], state: _IDLE_}, + [], + false + ) + ); + + it( + 'should return false when previous is in lobby with paired device and current is in the meeting', + testIsUserUnadmitted( + { + pairedWith: {intent: {type: _WAIT_}}, + joinedWith: {intent: {type: _OBSERVE_}}, + state: _IDLE_, + }, + { + devices: [{intent: {type: _OBSERVE_, associatedWith: 'pairedDeviceUrl'}}], + state: _IDLE_, + }, + [{url: 'pairedDeviceUrl', devices: [{intent: {type: _NONE_}}]}], false ) ); it( 'should return false when previous is in lobby and current is in meeting', - testIsUserUnadmitted({devices: [{intent: {type: _WAIT_}}], state: _IDLE_}, {}, false) + testIsUserUnadmitted({joinedWith: {intent: {type: _WAIT_}}, state: _IDLE_}, {}, [], false) ); it( 'should return true when previous is in meeting and current is in lobby', - testIsUserUnadmitted({}, {devices: [{intent: {type: _WAIT_}}], state: _IDLE_}, true) + testIsUserUnadmitted({}, {devices: [{intent: {type: _WAIT_}}], state: _IDLE_}, [], true) ); }); }); describe('isSharingBlocked', () => { + let parsedSelf; + + beforeEach(() => { + parsedSelf = SelfUtils.parse(self); + }); + it('should return isSharingBlockedChanged = true when changed', () => { const clonedSelf = cloneDeep(self); clonedSelf.isSharingBlocked = true; // different - const {updates} = SelfUtils.getSelves(self, clonedSelf); + const {updates} = SelfUtils.getSelves(parsedSelf, clonedSelf); assert.equal(updates.isSharingBlockedChanged, true); }); @@ -407,7 +481,7 @@ describe('plugin-meetings', () => { clonedSelf.isSharingBlocked = false; // same - const {updates} = SelfUtils.getSelves(self, clonedSelf); + const {updates} = SelfUtils.getSelves(parsedSelf, clonedSelf); assert.equal(updates.isSharingBlockedChanged, false); }); 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 c132dbb47ad..e7af7d81aa0 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/media/index.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/media/index.ts @@ -4,6 +4,7 @@ import Media from '@webex/plugin-meetings/src/media/index'; import {assert} from '@webex/test-helper-chai'; import sinon from 'sinon'; import StaticConfig from '@webex/plugin-meetings/src/common/config'; +import { BrowserInfo } from '@webex/web-capabilities'; describe('createMediaConnection', () => { let clock; @@ -197,7 +198,68 @@ describe('createMediaConnection', () => { sendMetricsInQueueCallback(); assert.calledOnce(rtcMetrics.sendMetricsInQueue); + }); + + it('multistream non-firefox does not care about stopIceGatheringAfterFirstRelayCandidate', () => { + const multistreamRoapMediaConnectionConstructorStub = sinon + .stub(InternalMediaCoreModule, 'MultistreamRoapMediaConnection') + .returns(fakeRoapMediaConnection); + + Media.createMediaConnection(true, 'some debug id', 'meeting id', { + stopIceGatheringAfterFirstRelayCandidate: true, + }); + assert.calledOnce(multistreamRoapMediaConnectionConstructorStub); + assert.calledWith( + multistreamRoapMediaConnectionConstructorStub, + { + iceServers: [] + }, + 'meeting id' + ); + }); + + it('multistream firefox stops gathering after first relay if stopIceGatheringAfterFirstRelayCandidate is true', () => { + const multistreamRoapMediaConnectionConstructorStub = sinon + .stub(InternalMediaCoreModule, 'MultistreamRoapMediaConnection') + .returns(fakeRoapMediaConnection); + + sinon.stub(BrowserInfo, 'isFirefox').returns(true); + + Media.createMediaConnection(true, 'some debug id', 'meeting id', { + stopIceGatheringAfterFirstRelayCandidate: true, + }); + assert.calledOnce(multistreamRoapMediaConnectionConstructorStub); + assert.calledWith( + multistreamRoapMediaConnectionConstructorStub, + { + iceServers: [], + doFullIce: true, + stopIceGatheringAfterFirstRelayCandidate: true, + }, + 'meeting id' + ); + }); + + it('multistream firefox continues gathering if stopIceGatheringAfterFirstRelayCandidate is false', () => { + const multistreamRoapMediaConnectionConstructorStub = sinon + .stub(InternalMediaCoreModule, 'MultistreamRoapMediaConnection') + .returns(fakeRoapMediaConnection); + + sinon.stub(BrowserInfo, 'isFirefox').returns(true); + Media.createMediaConnection(true, 'some debug id', 'meeting id', { + stopIceGatheringAfterFirstRelayCandidate: false, + }); + assert.calledOnce(multistreamRoapMediaConnectionConstructorStub); + assert.calledWith( + multistreamRoapMediaConnectionConstructorStub, + { + iceServers: [], + doFullIce: true, + stopIceGatheringAfterFirstRelayCandidate: false, + }, + 'meeting id' + ); }); [ diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/brbState.ts b/packages/@webex/plugin-meetings/test/unit/spec/meeting/brbState.ts index f0247156a42..cf790638a7a 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/brbState.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/brbState.ts @@ -110,5 +110,24 @@ describe('plugin-meetings', () => { assert.isTrue(brbState.state.server.enabled); }); + + it('invokes handleServerBrbUpdate with correct client state after syncing with server', async () => { + const sendLocalBrbStateToServerStub = sinon + .stub(brbState, 'sendLocalBrbStateToServer') + .resolves(); + + const handleServerBrbUpdateSpy = sinon.spy(brbState, 'handleServerBrbUpdate'); + + await brbState.enable(true, meeting.sendSlotManager); + + assert.isTrue(sendLocalBrbStateToServerStub.calledOnce); + + assert.isTrue(handleServerBrbUpdateSpy.calledOnceWith(brbState.state.client.enabled)); + + assert.isFalse(brbState.state.syncToServerInProgress); + + sendLocalBrbStateToServerStub.restore(); + handleServerBrbUpdateSpy.restore(); + }); }); }); 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 7905a3bdb07..b50f0445702 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js @@ -614,6 +614,22 @@ describe('plugin-meetings', () => { assert.calledWith(meeting.members.cancelPhoneInvite, uuid1); }); }); + describe('#cancelSIPInvite', () => { + it('should have #cancelSIPInvite', () => { + assert.exists(meeting.cancelSIPInvite); + }); + beforeEach(() => { + meeting.members.cancelSIPInvite = sinon.stub().returns(Promise.resolve(test1)); + }); + it('should proxy members #cancelSIPInvite and return a promise', async () => { + const cancel = meeting.cancelSIPInvite({memberId: uuid1}); + + assert.exists(cancel.then); + await cancel; + assert.calledOnce(meeting.members.cancelSIPInvite); + assert.calledWith(meeting.members.cancelSIPInvite, {memberId: uuid1}); + }); + }); describe('#admit', () => { it('should have #admit', () => { assert.exists(meeting.admit); 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 19e9ddfca90..754ca11f7cd 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meetings/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meetings/index.js @@ -413,6 +413,19 @@ describe('plugin-meetings', () => { }); }); + describe('#_toggleStopIceGatheringAfterFirstRelayCandidate', () => { + it('should have _toggleStopIceGatheringAfterFirstRelayCandidate', () => { + assert.equal(typeof webex.meetings._toggleStopIceGatheringAfterFirstRelayCandidate, 'function'); + }); + + describe('success', () => { + it('should update meetings to stop ICE candidates gathering after first relay candidate', () => { + webex.meetings._toggleStopIceGatheringAfterFirstRelayCandidate(true); + assert.equal(webex.meetings.config.stopIceGatheringAfterFirstRelayCandidate, true); + }); + }); + }); + describe('Public API Contracts', () => { describe('#register', () => { it('emits an event and resolves when register succeeds', async () => { diff --git a/packages/@webex/plugin-meetings/test/unit/spec/members/index.js b/packages/@webex/plugin-meetings/test/unit/spec/members/index.js index 1695eda99c2..f4fa74d3e75 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/members/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/members/index.js @@ -342,6 +342,32 @@ describe('plugin-meetings', () => { }); }); + describe('#cancelSIPInvite', () => { + const memberId = uuid.v4(); + it('should invoke cancelSIPInviteOptions from MembersUtil when cancelSIPInvite is called with valid params', async () => { + sandbox.spy(MembersUtil, 'cancelSIPInviteOptions'); + + const members = createMembers({url: url1}); + + await members.cancelSIPInvite({memberId}); + assert.calledOnce(MembersUtil.cancelSIPInviteOptions); + }); + + it('should throw a rejection if there is no locus url', async () => { + const members = createMembers({url: false}); + + assert.isRejected(members.cancelSIPInvite({memberId})); + }); + + it('should throw a rejection if memberId is not provided', async () => { + const members = createMembers({url: url1}); + + assert.isRejected(members.cancelSIPInvite({})); + assert.isRejected(members.cancelSIPInvite({memberId: null})); + assert.isRejected(members.cancelSIPInvite({memberId: undefined})); + }); + }); + describe('#assignRoles', () => { const fakeRoles = [ {type: 'PRESENTER', hasRole: true}, diff --git a/packages/@webex/plugin-meetings/test/unit/spec/members/request.js b/packages/@webex/plugin-meetings/test/unit/spec/members/request.js index 668e81f84cd..c6f97ae4293 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/members/request.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/members/request.js @@ -221,6 +221,29 @@ describe('plugin-meetings', () => { }); }); + describe('#cancelSIPInvite', () => { + const memberId = uuid.v4(); + it('sends a PUT to the locus endpoint', async () => { + const options = { + invitee: { + memberId, + }, + locusUrl: url1, + }; + + await membersRequest.cancelSIPInvite(options); + + checkRequest({ + method: 'PUT', + uri: url1, + body: { + actionType: 'REMOVE', + invitees: [{address: memberId}], + }, + }); + }); + }); + describe('#assignRolesMember', () => { it('sends a assignRolesMember PATCH to the locus endpoint', async () => { const locusUrl = url1; diff --git a/packages/@webex/plugin-meetings/test/unit/spec/members/utils.js b/packages/@webex/plugin-meetings/test/unit/spec/members/utils.js index f1d4a36f9da..3612736ab69 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/members/utils.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/members/utils.js @@ -390,5 +390,47 @@ describe('plugin-meetings', () => { }); }); }); + + describe('#cancelSIPInviteOptions', () => { + it('returns the correct options', () => { + const locusUrl = 'TestLocusUrl'; + const memberId = 'test'; + const invitee = {memberId}; + + assert.deepEqual( + MembersUtil.cancelSIPInviteOptions( + invitee, + locusUrl + ), + { + invitee, + locusUrl, + } + ); + }); + }); + + describe('#generateCancelSIPInviteRequestParams', () => { + it('returns the correct params', () => { + const locusUrl = 'TestLocusUrl'; + const memberId = 'test'; + const options = { + locusUrl, + invitee: {memberId} + }; + const body = { + actionType: 'REMOVE', + invitees: [{address: options.invitee.memberId}], + }; + + const uri = options.locusUrl; + + assert.deepEqual(MembersUtil.generateCancelSIPInviteRequestParams(options), { + method: HTTP_VERBS.PUT, + uri, + body, + }); + }); + }); }); }); diff --git a/packages/@webex/webex-core/src/index.js b/packages/@webex/webex-core/src/index.js index 55ad99c4ad4..fe513215792 100644 --- a/packages/@webex/webex-core/src/index.js +++ b/packages/@webex/webex-core/src/index.js @@ -28,6 +28,16 @@ export { HostMapInterceptor, } from './lib/services'; +export { + constants as serviceConstantsV2, + ServiceCatalogV2, + ServiceInterceptorV2, + ServerErrorInterceptorV2, + ServicesV2, + ServiceUrlV2, + HostMapInterceptorV2, +} from './lib/services-v2'; + export { makeWebexStore, makeWebexPluginStore, diff --git a/packages/@webex/webex-core/src/lib/services-v2/README.md b/packages/@webex/webex-core/src/lib/services-v2/README.md new file mode 100644 index 00000000000..e4ddd06977e --- /dev/null +++ b/packages/@webex/webex-core/src/lib/services-v2/README.md @@ -0,0 +1,3 @@ +[![standard-readme compliant](https://img.shields.io/badge/readme%20style-standard-brightgreen.svg?style=flat-square)](https://github.com/RichardLitt/standard-readme) + +> Services plugin update for WG4 DNSSec enabled users, this module is a work in progress. Please use at your own risk! This service will be updated many times to enable this feature. Continue to use /services. diff --git a/packages/@webex/webex-core/src/lib/services-v2/constants.js b/packages/@webex/webex-core/src/lib/services-v2/constants.js new file mode 100644 index 00000000000..4f5da831e07 --- /dev/null +++ b/packages/@webex/webex-core/src/lib/services-v2/constants.js @@ -0,0 +1,21 @@ +const NAMESPACE = 'services'; +const SERVICE_CATALOGS = ['discovery', 'limited', 'signin', 'postauth', 'custom']; + +const SERVICE_CATALOGS_ENUM_TYPES = { + STRING: 'SERVICE_CATALOGS_ENUM_TYPES_STRING', + NUMBER: 'SERVICE_CATALOGS_ENUM_TYPES_NUMBER', +}; + +// The default allowed domains that SDK can make requests to outside of service catalog +const COMMERCIAL_ALLOWED_DOMAINS = [ + 'wbx2.com', + 'ciscospark.com', + 'webex.com', + 'webexapis.com', + 'broadcloudpbx.com', + 'broadcloud.eu', + 'broadcloud.com.au', + 'broadcloudpbx.net', +]; + +export {SERVICE_CATALOGS_ENUM_TYPES, NAMESPACE, SERVICE_CATALOGS, COMMERCIAL_ALLOWED_DOMAINS}; diff --git a/packages/@webex/webex-core/src/lib/services-v2/index.js b/packages/@webex/webex-core/src/lib/services-v2/index.js new file mode 100644 index 00000000000..36a0997e6c9 --- /dev/null +++ b/packages/@webex/webex-core/src/lib/services-v2/index.js @@ -0,0 +1,23 @@ +/*! + * Copyright (c) 2015-2020 Cisco Systems, Inc. See LICENSE file. + */ +// import {registerInternalPlugin} from '../../webex-core'; + +import * as constants from './constants'; +// import ServerErrorInterceptor from './interceptors/server-error'; +// import ServiceInterceptor from './interceptors/service'; +export {default as ServicesV2} from './services-v2'; + +// registerInternalPlugin('services', ServicesV2, { +// interceptors: { +// ServiceInterceptor: ServiceInterceptor.create, +// ServerErrorInterceptor: ServerErrorInterceptor.create, +// }, +// }); + +export {constants}; +export {default as ServiceInterceptorV2} from './interceptors/service'; +export {default as ServerErrorInterceptorV2} from './interceptors/server-error'; +export {default as HostMapInterceptorV2} from './interceptors/hostmap'; +export {default as ServiceCatalogV2} from './service-catalog'; +export {default as ServiceUrlV2} from './service-url'; diff --git a/packages/@webex/webex-core/src/lib/services-v2/interceptors/hostmap.js b/packages/@webex/webex-core/src/lib/services-v2/interceptors/hostmap.js new file mode 100644 index 00000000000..0cf4370b7cb --- /dev/null +++ b/packages/@webex/webex-core/src/lib/services-v2/interceptors/hostmap.js @@ -0,0 +1,36 @@ +/*! + * Copyright (c) 2015-2024 Cisco Systems, Inc. See LICENSE file. + */ + +import {Interceptor} from '@webex/http-core'; + +/** + * This interceptor replaces the host in the request uri with the host from the hostmap + * It will attempt to do this for every request, but not all URIs will be in the hostmap + * URIs with hosts that are not in the hostmap will be left unchanged + */ +export default class HostMapInterceptor extends Interceptor { + /** + * @returns {HostMapInterceptor} + */ + static create() { + return new HostMapInterceptor({webex: this}); + } + + /** + * @see Interceptor#onRequest + * @param {Object} options + * @returns {Object} + */ + onRequest(options) { + if (options.uri) { + try { + options.uri = this.webex.internal.services.replaceHostFromHostmap(options.uri); + } catch (error) { + /* empty */ + } + } + + return options; + } +} diff --git a/packages/@webex/webex-core/src/lib/services-v2/interceptors/server-error.js b/packages/@webex/webex-core/src/lib/services-v2/interceptors/server-error.js new file mode 100644 index 00000000000..9cbf229d7b2 --- /dev/null +++ b/packages/@webex/webex-core/src/lib/services-v2/interceptors/server-error.js @@ -0,0 +1,48 @@ +/*! + * Copyright (c) 2015-2020 Cisco Systems, Inc. See LICENSE file. + */ + +import {Interceptor} from '@webex/http-core'; +import WebexHttpError from '../../webex-http-error'; +/** + * Changes server url when it fails + */ +export default class ServerErrorInterceptor extends Interceptor { + /** + * @returns {HAMessagingInterceptor} + */ + static create() { + // eslint-disable-next-line no-invalid-this + return new ServerErrorInterceptor({webex: this}); + } + + /** + * @see Interceptor#onResponseError + * @param {Object} options + * @param {Object} reason + * @returns {Object} + */ + onResponseError(options, reason) { + if ( + (reason instanceof WebexHttpError.InternalServerError || + reason instanceof WebexHttpError.BadGateway || + reason instanceof WebexHttpError.ServiceUnavailable) && + options.uri + ) { + const feature = this.webex.internal.device.features.developer.get('web-high-availability'); + + if (feature && feature.value) { + this.webex.internal.metrics.submitClientMetrics('web-ha', { + fields: {success: false}, + tags: {action: 'failed', error: reason.message, url: options.uri}, + }); + + return Promise.resolve(this.webex.internal.services.markFailedUrl(options.uri)).then(() => + Promise.reject(reason) + ); + } + } + + return Promise.reject(reason); + } +} diff --git a/packages/@webex/webex-core/src/lib/services-v2/interceptors/service.js b/packages/@webex/webex-core/src/lib/services-v2/interceptors/service.js new file mode 100644 index 00000000000..c7dc87a649a --- /dev/null +++ b/packages/@webex/webex-core/src/lib/services-v2/interceptors/service.js @@ -0,0 +1,101 @@ +/*! + * Copyright (c) 2015-2020 Cisco Systems, Inc. See LICENSE file. + */ + +import {Interceptor} from '@webex/http-core'; + +const trailingSlashes = /(?:^\/)|(?:\/$)/; + +/** + * @class + */ +export default class ServiceInterceptor extends Interceptor { + /** + * @returns {ServiceInterceptor} + */ + static create() { + /* eslint no-invalid-this: [0] */ + return new ServiceInterceptor({webex: this}); + } + + /* eslint-disable no-param-reassign */ + /** + * @see Interceptor#onRequest + * @param {Object} options - The request PTO. + * @returns {Object} - The mutated request PTO. + */ + onRequest(options) { + // Validate that the PTO includes a uri property. + if (options.uri) { + return options; + } + + // Normalize and validate the PTO. + this.normalizeOptions(options); + this.validateOptions(options); + + // Destructure commonly referenced namespaces. + const {services} = this.webex.internal; + const {service, resource, waitForServiceTimeout} = options; + + // Attempt to collect the service url. + return services + .waitForService({name: service, timeout: waitForServiceTimeout}) + .then((serviceUrl) => { + // Generate the combined service url and resource. + options.uri = this.generateUri(serviceUrl, resource); + + return options; + }) + .catch(() => + Promise.reject(new Error(`service-interceptor: '${service}' is not a known service`)) + ); + } + + /* eslint-disable class-methods-use-this */ + /** + * Generate a usable request uri string from a service url and a resouce. + * + * @param {string} serviceUrl - The service url. + * @param {string} [resource] - The resouce to be appended to the service url. + * @returns {string} - The combined service url and resource. + */ + generateUri(serviceUrl, resource = '') { + const formattedService = serviceUrl.replace(trailingSlashes, ''); + const formattedResource = resource.replace(trailingSlashes, ''); + + return `${formattedService}/${formattedResource}`; + } + + /** + * Normalizes request options relative to service identification. + * + * @param {Object} options - The request PTO. + * @returns {Object} - The mutated request PTO. + */ + normalizeOptions(options) { + // Validate if the api property is used. + if (options.api) { + // Assign the service property the value of the api property if necessary. + options.service = options.service || options.api; + delete options.api; + } + } + + /** + * Validates that the appropriate options for this interceptor are present. + * + * @param {Object} options - The request PTO. + * @returns {Object} - The mutated request PTO. + */ + validateOptions(options) { + if (!options.resource) { + throw new Error('a `resource` parameter is required'); + } + + if (!options.service) { + throw new Error("a valid 'service' parameter is required"); + } + } + /* eslint-enable class-methods-use-this, no-param-reassign */ +} diff --git a/packages/@webex/webex-core/src/lib/services-v2/metrics.js b/packages/@webex/webex-core/src/lib/services-v2/metrics.js new file mode 100644 index 00000000000..e81dde7cfc9 --- /dev/null +++ b/packages/@webex/webex-core/src/lib/services-v2/metrics.js @@ -0,0 +1,4 @@ +// Metrics for service catalog +export default { + JS_SDK_SERVICE_NOT_FOUND: 'JS_SDK_SERVICE_NOT_FOUND', +}; diff --git a/packages/@webex/webex-core/src/lib/services-v2/service-catalog.js b/packages/@webex/webex-core/src/lib/services-v2/service-catalog.js new file mode 100644 index 00000000000..db8da22a072 --- /dev/null +++ b/packages/@webex/webex-core/src/lib/services-v2/service-catalog.js @@ -0,0 +1,455 @@ +import Url from 'url'; + +import AmpState from 'ampersand-state'; + +import {union} from 'lodash'; +import ServiceUrl from './service-url'; + +/* eslint-disable no-underscore-dangle */ +/** + * @class + */ +const ServiceCatalog = AmpState.extend({ + namespace: 'ServiceCatalog', + + props: { + serviceGroups: [ + 'object', + true, + () => ({ + discovery: [], + override: [], + preauth: [], + postauth: [], + signin: [], + }), + ], + status: [ + 'object', + true, + () => ({ + discovery: { + ready: false, + collecting: false, + }, + override: { + ready: false, + collecting: false, + }, + preauth: { + ready: false, + collecting: false, + }, + postauth: { + ready: false, + collecting: false, + }, + signin: { + ready: false, + collecting: false, + }, + }), + ], + isReady: ['boolean', false, false], + allowedDomains: ['array', false, () => []], + }, + + /** + * @private + * Search the service url array to locate a `ServiceUrl` + * class object based on its name. + * @param {string} name + * @param {string} [serviceGroup] + * @returns {ServiceUrl} + */ + _getUrl(name, serviceGroup) { + const serviceUrls = + typeof serviceGroup === 'string' + ? this.serviceGroups[serviceGroup] || [] + : [ + ...this.serviceGroups.override, + ...this.serviceGroups.postauth, + ...this.serviceGroups.signin, + ...this.serviceGroups.preauth, + ...this.serviceGroups.discovery, + ]; + + return serviceUrls.find((serviceUrl) => serviceUrl.name === name); + }, + + /** + * @private + * Generate an array of `ServiceUrl`s that is organized from highest auth + * level to lowest auth level. + * @returns {Array} - array of `ServiceUrl`s + */ + _listServiceUrls() { + return [ + ...this.serviceGroups.override, + ...this.serviceGroups.postauth, + ...this.serviceGroups.signin, + ...this.serviceGroups.preauth, + ...this.serviceGroups.discovery, + ]; + }, + + /** + * @private + * Safely load one or more `ServiceUrl`s into this `Services` instance. + * @param {string} serviceGroup + * @param {Array} services + * @returns {Services} + */ + _loadServiceUrls(serviceGroup, services) { + // declare namespaces outside of loop + let existingService; + + services.forEach((service) => { + existingService = this._getUrl(service.name, serviceGroup); + + if (!existingService) { + this.serviceGroups[serviceGroup].push(service); + } + }); + + return this; + }, + + /** + * @private + * Safely unload one or more `ServiceUrl`s into this `Services` instance + * @param {string} serviceGroup + * @param {Array} services + * @returns {Services} + */ + _unloadServiceUrls(serviceGroup, services) { + // declare namespaces outside of loop + let existingService; + + services.forEach((service) => { + existingService = this._getUrl(service.name, serviceGroup); + + if (existingService) { + this.serviceGroups[serviceGroup].splice( + this.serviceGroups[serviceGroup].indexOf(existingService), + 1 + ); + } + }); + + return this; + }, + + /** + * Clear all collected catalog data and reset catalog status. + * + * @returns {void} + */ + clean() { + this.serviceGroups.preauth.length = 0; + this.serviceGroups.signin.length = 0; + this.serviceGroups.postauth.length = 0; + this.status.preauth = {ready: false}; + this.status.signin = {ready: false}; + this.status.postauth = {ready: false}; + }, + + /** + * Search over all service groups to find a cluster id based + * on a given url. + * @param {string} url - Must be parsable by `Url` + * @returns {string} - ClusterId of a given url + */ + findClusterId(url) { + const incomingUrlObj = Url.parse(url); + let serviceUrlObj; + + for (const key of Object.keys(this.serviceGroups)) { + for (const service of this.serviceGroups[key]) { + serviceUrlObj = Url.parse(service.defaultUrl); + + for (const host of service.hosts) { + if (incomingUrlObj.hostname === host.host && host.id) { + return host.id; + } + } + + if (serviceUrlObj.hostname === incomingUrlObj.hostname && service.hosts.length > 0) { + // no exact match, so try to grab the first home cluster + for (const host of service.hosts) { + if (host.homeCluster) { + return host.id; + } + } + + // no match found still, so return the first entry + return service.hosts[0].id; + } + } + } + + return undefined; + }, + + /** + * Search over all service groups and return a service value from a provided + * clusterId. Currently, this method will return either a service name, or a + * service url depending on the `value` parameter. If the `value` parameter + * is set to `name`, it will return a service name to be utilized within the + * Services plugin methods. + * @param {object} params + * @param {string} params.clusterId - clusterId of found service + * @param {boolean} [params.priorityHost = true] - returns priority host url if true + * @param {string} [params.serviceGroup] - specify service group + * @returns {object} service + * @returns {string} service.name + * @returns {string} service.url + */ + findServiceFromClusterId({clusterId, priorityHost = true, serviceGroup} = {}) { + const serviceUrls = + typeof serviceGroup === 'string' + ? this.serviceGroups[serviceGroup] || [] + : [ + ...this.serviceGroups.override, + ...this.serviceGroups.postauth, + ...this.serviceGroups.signin, + ...this.serviceGroups.preauth, + ...this.serviceGroups.discovery, + ]; + + const identifiedServiceUrl = serviceUrls.find((serviceUrl) => + serviceUrl.hosts.find((host) => host.id === clusterId) + ); + + if (identifiedServiceUrl) { + return { + name: identifiedServiceUrl.name, + url: identifiedServiceUrl.get(priorityHost, clusterId), + }; + } + + return undefined; + }, + + /** + * Find a service based on the provided url. + * @param {string} url - Must be parsable by `Url` + * @returns {serviceUrl} - ServiceUrl assocated with provided url + */ + findServiceUrlFromUrl(url) { + const serviceUrls = [ + ...this.serviceGroups.discovery, + ...this.serviceGroups.preauth, + ...this.serviceGroups.signin, + ...this.serviceGroups.postauth, + ...this.serviceGroups.override, + ]; + + return serviceUrls.find((serviceUrl) => { + // Check to see if the URL we are checking starts with the default URL + if (url.startsWith(serviceUrl.defaultUrl)) { + return true; + } + + // If not, we check to see if the alternate URLs match + // These are made by swapping the host of the default URL + // with that of an alternate host + for (const host of serviceUrl.hosts) { + const alternateUrl = new URL(serviceUrl.defaultUrl); + alternateUrl.host = host.host; + + if (url.startsWith(alternateUrl.toString())) { + return true; + } + } + + return false; + }); + }, + + /** + * Finds an allowed domain that matches a specific url. + * + * @param {string} url - The url to match the allowed domains against. + * @returns {string} - The matching allowed domain. + */ + findAllowedDomain(url) { + const urlObj = Url.parse(url); + + if (!urlObj.host) { + return undefined; + } + + return this.allowedDomains.find((allowedDomain) => urlObj.host.includes(allowedDomain)); + }, + + /** + * Get a service url from the current services list by name. + * @param {string} name + * @param {boolean} priorityHost + * @param {string} serviceGroup + * @returns {string} + */ + get(name, priorityHost, serviceGroup) { + const serviceUrl = this._getUrl(name, serviceGroup); + + return serviceUrl ? serviceUrl.get(priorityHost) : undefined; + }, + + /** + * Get the current allowed domains list. + * + * @returns {Array} - the current allowed domains list. + */ + getAllowedDomains() { + return [...this.allowedDomains]; + }, + + /** + * Creates an object where the keys are the service names + * and the values are the service urls. + * @param {boolean} priorityHost - use the highest priority if set to `true` + * @param {string} [serviceGroup] + * @returns {Record} + */ + list(priorityHost, serviceGroup) { + const output = {}; + + const serviceUrls = + typeof serviceGroup === 'string' + ? this.serviceGroups[serviceGroup] || [] + : [ + ...this.serviceGroups.discovery, + ...this.serviceGroups.preauth, + ...this.serviceGroups.signin, + ...this.serviceGroups.postauth, + ...this.serviceGroups.override, + ]; + + if (serviceUrls) { + serviceUrls.forEach((serviceUrl) => { + output[serviceUrl.name] = serviceUrl.get(priorityHost); + }); + } + + return output; + }, + + /** + * Mark a priority host service url as failed. + * This will mark the host associated with the + * `ServiceUrl` to be removed from the its + * respective host array, and then return the next + * viable host from the `ServiceUrls` host array, + * or the `ServiceUrls` default url if no other priority + * hosts are available, or if `noPriorityHosts` is set to + * `true`. + * @param {string} url + * @param {boolean} noPriorityHosts + * @returns {string} + */ + markFailedUrl(url, noPriorityHosts) { + const serviceUrl = this._getUrl( + Object.keys(this.list()).find((key) => this._getUrl(key).failHost(url)) + ); + + if (!serviceUrl) { + return undefined; + } + + return noPriorityHosts ? serviceUrl.get(false) : serviceUrl.get(true); + }, + + /** + * Set the allowed domains for the catalog. + * + * @param {Array} allowedDomains - allowed domains to be assigned. + * @returns {void} + */ + setAllowedDomains(allowedDomains) { + this.allowedDomains = [...allowedDomains]; + }, + + /** + * + * @param {Array} newAllowedDomains - new allowed domains to add to existing set of allowed domains + * @returns {void} + */ + addAllowedDomains(newAllowedDomains) { + this.allowedDomains = union(this.allowedDomains, newAllowedDomains); + }, + + /** + * Update the current list of `ServiceUrl`s against a provided + * service hostmap. + * @emits ServiceCatalog#preauthorized + * @emits ServiceCatalog#postauthorized + * @param {string} serviceGroup + * @param {object} serviceHostmap + * @returns {Services} + */ + updateServiceUrls(serviceGroup, serviceHostmap) { + const currentServiceUrls = this.serviceGroups[serviceGroup]; + + const unusedUrls = currentServiceUrls.filter((serviceUrl) => + serviceHostmap.every((item) => item.name !== serviceUrl.name) + ); + + this._unloadServiceUrls(serviceGroup, unusedUrls); + + serviceHostmap.forEach((serviceObj) => { + const service = this._getUrl(serviceObj.name, serviceGroup); + + if (service) { + service.defaultUrl = serviceObj.defaultUrl; + service.hosts = serviceObj.hosts || []; + } else { + this._loadServiceUrls(serviceGroup, [ + new ServiceUrl({ + ...serviceObj, + }), + ]); + } + }); + + this.status[serviceGroup].ready = true; + this.trigger(serviceGroup); + + return this; + }, + + /** + * Wait until the service catalog is available, + * or reject after a timeout of 60 seconds. + * @param {string} serviceGroup + * @param {number} [timeout] - in seconds + * @returns {Promise} + */ + waitForCatalog(serviceGroup, timeout) { + return new Promise((resolve, reject) => { + if (this.status[serviceGroup].ready) { + resolve(); + } + + const validatedTimeout = typeof timeout === 'number' && timeout >= 0 ? timeout : 60; + + const timeoutTimer = setTimeout( + () => + reject( + new Error( + `services: timeout occured while waiting for '${serviceGroup}' catalog to populate` + ) + ), + validatedTimeout * 1000 + ); + + this.once(serviceGroup, () => { + clearTimeout(timeoutTimer); + resolve(); + }); + }); + }, +}); +/* eslint-enable no-underscore-dangle */ + +export default ServiceCatalog; diff --git a/packages/@webex/webex-core/src/lib/services-v2/service-fed-ramp.js b/packages/@webex/webex-core/src/lib/services-v2/service-fed-ramp.js new file mode 100644 index 00000000000..b374597168d --- /dev/null +++ b/packages/@webex/webex-core/src/lib/services-v2/service-fed-ramp.js @@ -0,0 +1,5 @@ +export default { + hydra: 'https://api-usgov.webex.com/v1', + u2c: 'https://u2c.gov.ciscospark.com/u2c/api/v1', + sqdiscovery: 'https://ds.ciscospark.com/v1/region', // TODO: fedramp load balanced URL? this has been here for years as of now but now explicitly done +}; diff --git a/packages/@webex/webex-core/src/lib/services-v2/service-url.js b/packages/@webex/webex-core/src/lib/services-v2/service-url.js new file mode 100644 index 00000000000..786fe37050d --- /dev/null +++ b/packages/@webex/webex-core/src/lib/services-v2/service-url.js @@ -0,0 +1,124 @@ +import Url from 'url'; + +import AmpState from 'ampersand-state'; + +/* eslint-disable no-underscore-dangle */ +/** + * @class + */ +const ServiceUrl = AmpState.extend({ + namespace: 'ServiceUrl', + + props: { + defaultUrl: ['string', true, undefined], + hosts: ['array', false, () => []], + name: ['string', true, undefined], + }, + + /** + * Generate a host url based on the host + * uri provided. + * @param {string} hostUri + * @returns {string} + */ + _generateHostUrl(hostUri) { + const url = Url.parse(this.defaultUrl); + + // setting url.hostname will not apply during Url.format(), set host via + // a string literal instead. + url.host = `${hostUri}${url.port ? `:${url.port}` : ''}`; + + return Url.format(url); + }, + + /** + * Generate a list of urls based on this + * `ServiceUrl`'s known hosts. + * @returns {string[]} + */ + _getHostUrls() { + return this.hosts.map((host) => ({ + url: this._generateHostUrl(host.host), + priority: host.priority, + })); + }, + + /** + * Get the current host url with the highest priority. If a clusterId is not + * provided, this will only return a URL with a filtered host that has the + * `homeCluster` value set to `true`. + * + * @param {string} [clusterId] - The clusterId to filter for a priority host. + * @returns {string} - The priority host url. + */ + _getPriorityHostUrl(clusterId) { + if (this.hosts.length === 0) { + return this.defaultUrl; + } + + let filteredHosts = clusterId + ? this.hosts.filter((host) => host.id === clusterId) + : this.hosts.filter((host) => host.homeCluster); + + const aliveHosts = filteredHosts.filter((host) => !host.failed); + + filteredHosts = + aliveHosts.length === 0 + ? filteredHosts.map((host) => { + /* eslint-disable-next-line no-param-reassign */ + host.failed = false; + + return host; + }) + : aliveHosts; + + return this._generateHostUrl( + filteredHosts.reduce( + (previous, current) => + previous.priority > current.priority || !previous.homeCluster ? current : previous, + {} + ).host + ); + }, + + /** + * Attempt to mark a host from this `ServiceUrl` as failed and return true + * if the provided url has a host that could be successfully marked as failed. + * + * @param {string} url + * @returns {boolean} + */ + failHost(url) { + if (url === this.defaultUrl) { + return true; + } + + const {hostname} = Url.parse(url); + const foundHost = this.hosts.find((hostObj) => hostObj.host === hostname); + + if (foundHost) { + foundHost.failed = true; + } + + return foundHost !== undefined; + }, + + /** + * Get the current `defaultUrl` or generate a url using the host with the + * highest priority via host rendering. + * + * @param {boolean} [priorityHost] - Retrieve the priority host. + * @param {string} [clusterId] - Cluster to match a host against. + * @returns {string} - The full service url. + */ + get(priorityHost, clusterId) { + if (!priorityHost) { + return this.defaultUrl; + } + + return this._getPriorityHostUrl(clusterId); + }, +}); +/* eslint-enable no-underscore-dangle */ + +export default ServiceUrl; diff --git a/packages/@webex/webex-core/src/lib/services-v2/services-v2.js b/packages/@webex/webex-core/src/lib/services-v2/services-v2.js new file mode 100644 index 00000000000..41f33a2c8a5 --- /dev/null +++ b/packages/@webex/webex-core/src/lib/services-v2/services-v2.js @@ -0,0 +1,971 @@ +import sha256 from 'crypto-js/sha256'; + +import {union, unionBy} from 'lodash'; +import WebexPlugin from '../webex-plugin'; + +import METRICS from './metrics'; +import ServiceCatalog from './service-catalog'; +import fedRampServices from './service-fed-ramp'; +import {COMMERCIAL_ALLOWED_DOMAINS} from './constants'; + +const trailingSlashes = /(?:^\/)|(?:\/$)/; + +// The default cluster when one is not provided (usually as 'US' from hydra) +export const DEFAULT_CLUSTER = 'urn:TEAM:us-east-2_a'; +// The default service name for convo (currently identityLookup due to some weird CSB issue) +export const DEFAULT_CLUSTER_SERVICE = 'identityLookup'; + +const CLUSTER_SERVICE = process.env.WEBEX_CONVERSATION_CLUSTER_SERVICE || DEFAULT_CLUSTER_SERVICE; +const DEFAULT_CLUSTER_IDENTIFIER = + process.env.WEBEX_CONVERSATION_DEFAULT_CLUSTER || `${DEFAULT_CLUSTER}:${CLUSTER_SERVICE}`; + +/* eslint-disable no-underscore-dangle */ +/** + * @class + */ +const Services = WebexPlugin.extend({ + namespace: 'Services', + + props: { + validateDomains: ['boolean', false, true], + initFailed: ['boolean', false, false], + }, + + _catalogs: new WeakMap(), + + _activeServices: {}, + + _services: [], + + /** + * @private + * Get the current catalog based on the assocaited + * webex instance. + * @returns {ServiceCatalog} + */ + _getCatalog() { + return this._catalogs.get(this.webex); + }, + + /** + * Get a service url from the current services list by name + * from the associated instance catalog. + * @param {string} name + * @param {boolean} [priorityHost] + * @param {string} [serviceGroup] + * @returns {string|undefined} + */ + get(name, priorityHost, serviceGroup) { + const catalog = this._getCatalog(); + + return catalog.get(name, priorityHost, serviceGroup); + }, + + /** + * Determine if a whilelist exists in the service catalog. + * + * @returns {boolean} - True if a allowed domains list exists. + */ + hasAllowedDomains() { + const catalog = this._getCatalog(); + + return catalog.getAllowedDomains().length > 0; + }, + + /** + * Generate a service catalog as an object from + * the associated instance catalog. + * @param {boolean} [priorityHost] - use highest priority host if set to `true` + * @param {string} [serviceGroup] + * @returns {Record} + */ + list(priorityHost, serviceGroup) { + const catalog = this._getCatalog(); + + return catalog.list(priorityHost, serviceGroup); + }, + + /** + * Mark a priority host service url as failed. + * This will mark the host associated with the + * `ServiceUrl` to be removed from the its + * respective host array, and then return the next + * viable host from the `ServiceUrls` host array, + * or the `ServiceUrls` default url if no other priority + * hosts are available, or if `noPriorityHosts` is set to + * `true`. + * @param {string} url + * @param {boolean} noPriorityHosts + * @returns {string} + */ + markFailedUrl(url, noPriorityHosts) { + const catalog = this._getCatalog(); + + return catalog.markFailedUrl(url, noPriorityHosts); + }, + + /** + * saves all the services from the pre and post catalog service + * @param {Object} activeServices + * @returns {void} + */ + _updateActiveServices(activeServices) { + this._activeServices = {...this._activeServices, ...activeServices}; + }, + + /** + * saves the hostCatalog object + * @param {Object} services + * @returns {void} + */ + _updateServices(services) { + this._services = unionBy(services, this._services, 'id'); + }, + + /** + * Update a list of `serviceUrls` to the most current + * catalog via the defined `discoveryUrl` then returns the current + * list of services. + * @param {object} [param] + * @param {string} [param.from] - This accepts `limited` or `signin` + * @param {object} [param.query] - This accepts `email`, `orgId` or `userId` key values + * @param {string} [param.query.email] - must be a standard-format email + * @param {string} [param.query.orgId] - must be an organization id + * @param {string} [param.query.userId] - must be a user id + * @param {string} [param.token] - used for signin catalog + * @returns {Promise} + */ + updateServices({from, query, token, forceRefresh} = {}) { + const catalog = this._getCatalog(); + let formattedQuery; + let serviceGroup; + + // map catalog name to service group name. + switch (from) { + case 'limited': + serviceGroup = 'preauth'; + break; + case 'signin': + serviceGroup = 'signin'; + break; + default: + serviceGroup = 'postauth'; + break; + } + + // confirm catalog update for group is not in progress. + if (catalog.status[serviceGroup].collecting) { + return this.waitForCatalog(serviceGroup); + } + + catalog.status[serviceGroup].collecting = true; + + if (serviceGroup === 'preauth') { + const queryKey = query && Object.keys(query)[0]; + + if (!['email', 'emailhash', 'userId', 'orgId', 'mode'].includes(queryKey)) { + return Promise.reject( + new Error('a query param of email, emailhash, userId, orgId, or mode is required') + ); + } + } + // encode email when query key is email + if (serviceGroup === 'preauth' || serviceGroup === 'signin') { + const queryKey = Object.keys(query)[0]; + + formattedQuery = {}; + + if (queryKey === 'email' && query.email) { + formattedQuery.emailhash = sha256(query.email.toLowerCase()).toString(); + } else { + formattedQuery[queryKey] = query[queryKey]; + } + } + + return this._fetchNewServiceHostmap({ + from, + token, + query: formattedQuery, + forceRefresh, + }) + .then((serviceHostMap) => { + catalog.updateServiceUrls(serviceGroup, serviceHostMap); + this.updateCredentialsConfig(); + catalog.status[serviceGroup].collecting = false; + }) + .catch((error) => { + catalog.status[serviceGroup].collecting = false; + + return Promise.reject(error); + }); + }, + + /** + * User validation parameter transfer object for {@link validateUser}. + * @param {object} ValidateUserPTO + * @property {string} ValidateUserPTO.email - The email of the user. + * @property {string} [ValidateUserPTO.reqId] - The activation requester. + * @property {object} [ValidateUserPTO.activationOptions] - Extra options to pass when sending the activation + * @property {object} [ValidateUserPTO.preloginUserId] - The prelogin user id to set when sending the activation. + */ + + /** + * User validation return transfer object for {@link validateUser}. + * @param {object} ValidateUserRTO + * @property {boolean} ValidateUserRTO.activated - If the user is activated. + * @property {boolean} ValidateUserRTO.exists - If the user exists. + * @property {string} ValidateUserRTO.details - A descriptive status message. + * @property {object} ValidateUserRTO.user - **License** service user object. + */ + + /** + * Validate if a user is activated and update the service catalogs as needed + * based on the user's activation status. + * + * @param {ValidateUserPTO} - The parameter transfer object. + * @returns {ValidateUserRTO} - The return transfer object. + */ + validateUser({ + email, + reqId = 'WEBCLIENT', + forceRefresh = false, + activationOptions = {}, + preloginUserId, + }) { + this.logger.info('services: validating a user'); + + // Validate that an email parameter key was provided. + if (!email) { + return Promise.reject(new Error('`email` is required')); + } + + // Destructure the credentials object. + const {canAuthorize} = this.webex.credentials; + + // Validate that the user is already authorized. + if (canAuthorize) { + return this.updateServices({forceRefresh}) + .then(() => this.webex.credentials.getUserToken()) + .then((token) => + this.sendUserActivation({ + email, + reqId, + token: token.toString(), + activationOptions, + preloginUserId, + }) + ) + .then((userObj) => ({ + activated: true, + exists: true, + details: 'user is authorized via a user token', + user: userObj, + })); + } + + // Destructure the client authorization details. + /* eslint-disable camelcase */ + const {client_id, client_secret} = this.webex.credentials.config; + + // Validate that client authentication details exist. + if (!client_id || !client_secret) { + return Promise.reject(new Error('client authentication details are not available')); + } + /* eslint-enable camelcase */ + + // Declare a class-memeber-scoped token for usage within the promise chain. + let token; + + // Begin client authentication user validation. + return ( + this.collectPreauthCatalog({email}) + .then(() => { + // Retrieve the service url from the updated catalog. This is required + // since `WebexCore` is usually not fully initialized at the time this + // request completes. + const idbrokerService = this.get('idbroker', true); + + // Collect the client auth token. + return this.webex.credentials.getClientToken({ + uri: `${idbrokerService}idb/oauth2/v1/access_token`, + scope: 'webexsquare:admin webexsquare:get_conversation Identity:SCIM', + }); + }) + .then((tokenObj) => { + // Generate the token string. + token = tokenObj.toString(); + + // Collect the signin catalog using the client auth information. + return this.collectSigninCatalog({email, token, forceRefresh}); + }) + // Validate if collecting the signin catalog failed and populate the RTO + // with the appropriate content. + .catch((error) => ({ + exists: error.name !== 'NotFound', + activated: false, + details: + error.name !== 'NotFound' + ? 'user exists but is not activated' + : 'user does not exist and is not activated', + })) + // Validate if the previous promise resolved with an RTO and populate the + // new RTO accordingly. + .then((rto) => + Promise.all([ + rto || { + activated: true, + exists: true, + details: 'user exists and is activated', + }, + this.sendUserActivation({ + email, + reqId, + token, + activationOptions, + preloginUserId, + }), + ]) + ) + .then(([rto, user]) => ({...rto, user})) + .catch((error) => { + const response = { + statusCode: error.statusCode, + responseText: error.body && error.body.message, + body: error.body, + }; + + return Promise.reject(response); + }) + ); + }, + + /** + * Get user meeting preferences (preferred webex site). + * + * @returns {object} - User Information including user preferrences . + */ + getMeetingPreferences() { + return this.request({ + method: 'GET', + service: 'hydra', + resource: 'meetingPreferences', + }) + .then((res) => { + this.logger.info('services: received user region info'); + + return res.body; + }) + .catch((err) => { + this.logger.info('services: was not able to fetch user login information', err); + // resolve successfully even if request failed + }); + }, + + /** + * Fetches client region info such as countryCode and timezone. + * + * @returns {object} - The region info object. + */ + fetchClientRegionInfo() { + const {services} = this.webex.config; + + return this.request({ + uri: services.discovery.sqdiscovery, + addAuthHeader: false, + headers: { + 'spark-user-agent': null, + }, + timeout: 5000, + }) + .then((res) => { + this.logger.info('services: received user region info'); + + return res.body; + }) + .catch((err) => { + this.logger.info('services: was not able to get user region info', err); + // resolve successfully even if request failed + }); + }, + + /** + * User activation parameter transfer object for {@link sendUserActivation}. + * @typedef {object} SendUserActivationPTO + * @property {string} SendUserActivationPTO.email - The email of the user. + * @property {string} SendUserActivationPTO.reqId - The activation requester. + * @property {string} SendUserActivationPTO.token - The client auth token. + * @property {object} SendUserActivationPTO.activationOptions - Extra options to pass when sending the activation. + * @property {object} SendUserActivationPTO.preloginUserId - The prelogin user id to set when sending the activation. + */ + + /** + * Send a request to activate a user using a client token. + * + * @param {SendUserActivationPTO} - The Parameter transfer object. + * @returns {LicenseDTO} - The DTO returned from the **License** service. + */ + sendUserActivation({email, reqId, token, activationOptions, preloginUserId}) { + this.logger.info('services: sending user activation request'); + let countryCode; + let timezone; + + // try to fetch client region info first + return ( + this.fetchClientRegionInfo() + .then((clientRegionInfo) => { + if (clientRegionInfo) { + ({countryCode, timezone} = clientRegionInfo); + } + + // Send the user activation request to the **License** service. + return this.request({ + service: 'license', + resource: 'users/activations', + method: 'POST', + headers: { + accept: 'application/json', + authorization: token, + 'x-prelogin-userid': preloginUserId, + }, + body: { + email, + reqId, + countryCode, + timeZone: timezone, + ...activationOptions, + }, + shouldRefreshAccessToken: false, + }); + }) + // On success, return the **License** user object. + .then(({body}) => body) + // On failure, reject with error from **License**. + .catch((error) => Promise.reject(error)) + ); + }, + + /** + * Updates a given service group i.e. preauth, signin, postauth with a new hostmap. + * @param {string} serviceGroup - preauth, signin, postauth + * @param {object} hostMap - The new hostmap to update the service group with. + * @returns {Promise} + */ + updateCatalog(serviceGroup, hostMap) { + const catalog = this._getCatalog(); + + const serviceHostMap = this._formatReceivedHostmap(hostMap); + + return catalog.updateServiceUrls(serviceGroup, serviceHostMap); + }, + + /** + * simplified method to update the preauth catalog via email + * + * @param {object} query + * @param {string} query.email - A standard format email. + * @param {string} query.orgId - The user's OrgId. + * @param {boolean} forceRefresh - Boolean to bypass u2c cache control header + * @returns {Promise} + */ + collectPreauthCatalog(query, forceRefresh = false) { + if (!query) { + return this.updateServices({ + from: 'limited', + query: {mode: 'DEFAULT_BY_PROXIMITY'}, + forceRefresh, + }); + } + + return this.updateServices({from: 'limited', query, forceRefresh}); + }, + + /** + * simplified method to update the signin catalog via email and token + * @param {object} param + * @param {string} param.email - must be a standard-format email + * @param {string} param.token - must be a client token + * @returns {Promise} + */ + collectSigninCatalog({email, token, forceRefresh} = {}) { + if (!email) { + return Promise.reject(new Error('`email` is required')); + } + if (!token) { + return Promise.reject(new Error('`token` is required')); + } + + return this.updateServices({ + from: 'signin', + query: {email}, + token, + forceRefresh, + }); + }, + + /** + * Updates credentials config to utilize u2c catalog + * urls. + * @returns {void} + */ + updateCredentialsConfig() { + const {idbroker, identity} = this.list(true); + + if (idbroker && identity) { + const {authorizationString, authorizeUrl} = this.webex.config.credentials; + + // This must be set outside of the setConfig method used to assign the + // idbroker and identity url values. + this.webex.config.credentials.authorizeUrl = authorizationString + ? authorizeUrl + : `${idbroker.replace(trailingSlashes, '')}/idb/oauth2/v1/authorize`; + + this.webex.setConfig({ + credentials: { + idbroker: { + url: idbroker.replace(trailingSlashes, ''), // remove trailing slash + }, + identity: { + url: identity.replace(trailingSlashes, ''), // remove trailing slash + }, + }, + }); + } + }, + + /** + * Wait until the service catalog is available, + * or reject afte ra timeout of 60 seconds. + * @param {string} serviceGroup + * @param {number} [timeout] - in seconds + * @returns {Promise} + */ + waitForCatalog(serviceGroup, timeout) { + const catalog = this._getCatalog(); + const {supertoken} = this.webex.credentials; + + if ( + serviceGroup === 'postauth' && + supertoken && + supertoken.access_token && + !catalog.status.postauth.collecting && + !catalog.status.postauth.ready + ) { + if (!catalog.status.preauth.ready) { + return this.initServiceCatalogs(); + } + + return this.updateServices(); + } + + return catalog.waitForCatalog(serviceGroup, timeout); + }, + + /** + * Service waiting parameter transfer object for {@link waitForService}. + * + * @typedef {object} WaitForServicePTO + * @property {string} [WaitForServicePTO.name] - The service name. + * @property {string} [WaitForServicePTO.url] - The service url. + * @property {string} [WaitForServicePTO.timeout] - wait duration in seconds. + */ + + /** + * Wait until the service has been ammended to any service catalog. This + * method prioritizes the service name over the service url when searching. + * + * @param {WaitForServicePTO} - The parameter transfer object. + * @returns {Promise} - Resolves to the priority host of a service. + */ + waitForService({name, timeout = 5, url}) { + const {services} = this.webex.config; + + // Save memory by grabbing the catalog after there isn't a priortyURL + const catalog = this._getCatalog(); + + const fetchFromServiceUrl = services.servicesNotNeedValidation.find( + (service) => service === name + ); + + if (fetchFromServiceUrl) { + return Promise.resolve(this._activeServices[name]); + } + + const priorityUrl = this.get(name, true); + const priorityUrlObj = this.getServiceFromUrl(url); + + if (priorityUrl || priorityUrlObj) { + return Promise.resolve(priorityUrl || priorityUrlObj.priorityUrl); + } + + if (catalog.isReady) { + if (url) { + return Promise.resolve(url); + } + + this.webex.internal.metrics.submitClientMetrics(METRICS.JS_SDK_SERVICE_NOT_FOUND, { + fields: {service_name: name}, + }); + + return Promise.reject( + new Error(`services: service '${name}' was not found in any of the catalogs`) + ); + } + + return new Promise((resolve, reject) => { + const groupsToCheck = ['preauth', 'signin', 'postauth']; + const checkCatalog = (catalogGroup) => + catalog + .waitForCatalog(catalogGroup, timeout) + .then(() => { + const scopedPriorityUrl = this.get(name, true); + const scopedPrioriryUrlObj = this.getServiceFromUrl(url); + + if (scopedPriorityUrl || scopedPrioriryUrlObj) { + resolve(scopedPriorityUrl || scopedPrioriryUrlObj.priorityUrl); + } + }) + .catch(() => undefined); + + Promise.all(groupsToCheck.map((group) => checkCatalog(group))).then(() => { + this.webex.internal.metrics.submitClientMetrics(METRICS.JS_SDK_SERVICE_NOT_FOUND, { + fields: {service_name: name}, + }); + reject(new Error(`services: service '${name}' was not found after waiting`)); + }); + }); + }, + + /** + * Looks up the hostname in the host catalog + * and replaces it with the first host if it finds it + * @param {string} uri + * @returns {string} uri with the host replaced + */ + replaceHostFromHostmap(uri) { + const url = new URL(uri); + const hostCatalog = this._services; + + if (!hostCatalog) { + return uri; + } + + const host = hostCatalog[url.host]; + + if (host && host[0]) { + const newHost = host[0].host; + + url.host = newHost; + + return url.toString(); + } + + return uri; + }, + + /** + * @private + * Organize a received hostmap from a service + * @param {object} serviceHostmap + * catalog endpoint. + * @returns {object} + */ + _formatReceivedHostmap({services, activeServices}) { + const formattedHostmap = services.map(({id, serviceName, serviceUrls}) => { + const formattedServiceUrls = serviceUrls.map((serviceUrl) => ({ + host: new URL(serviceUrl.baseUrl).host, + ...serviceUrl, + })); + + return { + id, + serviceName, + serviceUrls: formattedServiceUrls, + }; + }); + this._updateActiveServices(activeServices); + this._updateServices(services); + + return formattedHostmap; + }, + + /** + * Get the clusterId associated with a URL string. + * @param {string} url + * @returns {string} - Cluster ID of url provided + */ + getClusterId(url) { + const catalog = this._getCatalog(); + + return catalog.findClusterId(url); + }, + + /** + * Get a service value from a provided clusterId. This method will + * return an object containing both the name and url of a found service. + * @param {object} params + * @param {string} params.clusterId - clusterId of found service + * @param {boolean} [params.priorityHost] - returns priority host url if true + * @param {string} [params.serviceGroup] - specify service group + * @returns {object} service + * @returns {string} service.name + * @returns {string} service.url + */ + getServiceFromClusterId(params) { + const catalog = this._getCatalog(); + + return catalog.findServiceFromClusterId(params); + }, + + /** + * @param {String} cluster the cluster containing the id + * @param {UUID} [id] the id of the conversation. + * If empty, just return the base URL. + * @returns {String} url of the service + */ + getServiceUrlFromClusterId({cluster = 'us'} = {}) { + let clusterId = cluster === 'us' ? DEFAULT_CLUSTER_IDENTIFIER : cluster; + + // Determine if cluster has service name (non-US clusters from hydra do not) + if (clusterId.split(':').length < 4) { + // Add Service to cluster identifier + clusterId = `${cluster}:${CLUSTER_SERVICE}`; + } + + const {url} = this.getServiceFromClusterId({clusterId}) || {}; + + if (!url) { + throw Error(`Could not find service for cluster [${cluster}]`); + } + + return url; + }, + + /** + * Get a service object from a service url if the service url exists in the + * catalog. + * + * @param {string} url - The url to be validated. + * @returns {object} - Service object. + * @returns {object.name} - The name of the service found. + * @returns {object.priorityUrl} - The priority url of the found service. + * @returns {object.defaultUrl} - The default url of the found service. + */ + getServiceFromUrl(url = '') { + const service = this._getCatalog().findServiceUrlFromUrl(url); + + if (!service) { + return undefined; + } + + return { + name: service.name, + priorityUrl: service.get(true), + defaultUrl: service.get(), + }; + }, + + /** + * Determine if a provided url is in the catalog's allowed domains. + * + * @param {string} url - The url to match allowed domains against. + * @returns {boolean} - True if the url provided is allowed. + */ + isAllowedDomainUrl(url) { + const catalog = this._getCatalog(); + + return !!catalog.findAllowedDomain(url); + }, + + /** + * Converts the host portion of the url from default host + * to a priority host + * + * @param {string} url a service url that contains a default host + * @returns {string} a service url that contains the top priority host. + * @throws if url isn't a service url + */ + convertUrlToPriorityHostUrl(url = '') { + const data = this.getServiceFromUrl(url); + + if (!data) { + throw Error(`No service associated with url: [${url}]`); + } + + return url.replace(data.defaultUrl, data.priorityUrl); + }, + + /** + * @private + * Simplified method wrapper for sending a request to get + * an updated service hostmap. + * @param {object} [param] + * @param {string} [param.from] - This accepts `limited` or `signin` + * @param {object} [param.query] - This accepts `email`, `orgId` or `userId` key values + * @param {string} [param.query.email] - must be a standard-format email + * @param {string} [param.query.orgId] - must be an organization id + * @param {string} [param.query.userId] - must be a user id + * @param {string} [param.token] - used for signin catalog + * @returns {Promise} + */ + _fetchNewServiceHostmap({from, query, token, forceRefresh} = {}) { + const service = 'u2c'; + const resource = from ? `/${from}/catalog` : '/catalog'; + const qs = {...(query || {}), format: 'hostmap'}; + + if (forceRefresh) { + qs.timestamp = new Date().getTime(); + } + + const requestObject = { + method: 'GET', + service, + resource, + qs, + }; + + if (token) { + requestObject.headers = {authorization: token}; + } + + return this.webex.internal.newMetrics.callDiagnosticLatencies + .measureLatency(() => this.request(requestObject), 'internal.get.u2c.time') + .then(({body}) => this._formatReceivedHostmap(body)); + }, + + /** + * Initialize the discovery services and the whitelisted services. + * + * @returns {void} + */ + initConfig() { + // Get the catalog and destructure the services config. + const catalog = this._getCatalog(); + const {services, fedramp} = this.webex.config; + + // Validate that the services configuration exists. + if (services) { + if (fedramp) { + services.discovery = fedRampServices; + } + // Check for discovery services. + if (services.discovery) { + // Format the discovery configuration into an injectable array. + const formattedDiscoveryServices = Object.keys(services.discovery).map((key) => ({ + name: key, + defaultUrl: services.discovery[key], + })); + + // Inject formatted discovery services into services catalog. + catalog.updateServiceUrls('discovery', formattedDiscoveryServices); + } + + if (services.override) { + // Format the override configuration into an injectable array. + const formattedOverrideServices = Object.keys(services.override).map((key) => ({ + name: key, + defaultUrl: services.override[key], + })); + + // Inject formatted override services into services catalog. + catalog.updateServiceUrls('override', formattedOverrideServices); + } + + // if not fedramp, append on the commercialAllowedDomains + if (!fedramp) { + services.allowedDomains = union(services.allowedDomains, COMMERCIAL_ALLOWED_DOMAINS); + } + + // Check for allowed host domains. + if (services.allowedDomains) { + // Store the allowed domains as a property of the catalog. + catalog.setAllowedDomains(services.allowedDomains); + } + + // Set `validateDomains` property to match configuration + this.validateDomains = services.validateDomains; + } + }, + + /** + * Make the initial requests to collect the root catalogs. + * + * @returns {Promise} - Errors if the token is unavailable. + */ + initServiceCatalogs() { + this.logger.info('services: initializing initial service catalogs'); + + // Destructure the credentials plugin. + const {credentials} = this.webex; + + // Init a promise chain. Must be done as a Promise.resolve() to allow + // credentials#getOrgId() to properly throw. + return ( + Promise.resolve() + // Get the user's OrgId. + .then(() => credentials.getOrgId()) + // Begin collecting the preauth/limited catalog. + .then((orgId) => this.collectPreauthCatalog({orgId})) + .then(() => { + // Validate if the token is authorized. + if (credentials.canAuthorize) { + // Attempt to collect the postauth catalog. + return this.updateServices().catch(() => { + this.initFailed = true; + this.logger.warn('services: cannot retrieve postauth catalog'); + }); + } + + // Return a resolved promise for consistent return value. + return Promise.resolve(); + }) + ); + }, + + /** + * Initializer + * + * @instance + * @memberof Services + * @returns {Services} + */ + initialize() { + const catalog = new ServiceCatalog(); + this._catalogs.set(this.webex, catalog); + + // Listen for configuration changes once. + this.listenToOnce(this.webex, 'change:config', () => { + this.initConfig(); + }); + + // wait for webex instance to be ready before attempting + // to update the service catalogs + this.listenToOnce(this.webex, 'ready', () => { + const {supertoken} = this.webex.credentials; + // Validate if the supertoken exists. + if (supertoken && supertoken.access_token) { + this.initServiceCatalogs() + .then(() => { + catalog.isReady = true; + }) + .catch((error) => { + this.initFailed = true; + this.logger.error( + `services: failed to init initial services when credentials available, ${error?.message}` + ); + }); + } else { + const {email} = this.webex.config; + + this.collectPreauthCatalog(email ? {email} : undefined).catch((error) => { + this.initFailed = true; + this.logger.error( + `services: failed to init initial services when no credentials available, ${error?.message}` + ); + }); + } + }); + }, +}); +/* eslint-enable no-underscore-dangle */ + +export default Services; diff --git a/packages/@webex/webex-core/test/fixtures/host-catalog-v2.js b/packages/@webex/webex-core/test/fixtures/host-catalog-v2.js new file mode 100644 index 00000000000..ef1268419c2 --- /dev/null +++ b/packages/@webex/webex-core/test/fixtures/host-catalog-v2.js @@ -0,0 +1,247 @@ +export const serviceHostmapV2 = { + 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://prod-achm-message.svc.webex.com/conversation/api/v1', + priority: 1, + }, + { + baseUrl: 'https://conv-a.wbx2.com/conversation/api/v1', + priority: 2, + }, + ], + }, + { + id: 'urn:TEAM:me-central-1_d:conversation', + serviceName: 'conversation', + serviceUrls: [ + { + baseUrl: 'https://prod-adxb-message.svc.webex.com/conversation/api/v1', + priority: 1, + }, + { + baseUrl: 'https://conv-d.wbx2.com/conversation/api/v1', + priority: 2, + }, + ], + }, + { + id: 'urn:TEAM:us-east-2_a:idbroker', + serviceName: 'idbroker', + serviceUrls: [ + { + baseUrl: 'https://prod-adxb-message.svc.webex.com/idbroker/api/v1', + priority: 1, + }, + { + baseUrl: 'https://idbroker.webex.com/idb/api/v1', + priority: 2, + }, + ], + }, + { + id: 'urn:TEAM:me-central-1_d:idbroker', + serviceName: 'idbroker', + serviceUrls: [ + { + baseUrl: 'https://prod-adxb-message.svc.webex.com/idbroker/api/v1', + priority: 1, + }, + { + baseUrl: 'https://conv-d.wbx2.com/idbroker/api/v1', + priority: 2, + }, + ], + }, + { + id: 'urn:TEAM:us-east-2_a:locus', + serviceName: 'locus', + serviceUrls: [ + { + baseUrl: 'https://prod-adxb-message.svc.webex.com/locus/api/v1', + priority: 1, + }, + { + baseUrl: 'https://locus-a.wbx2.com/locus/api/v1', + priority: 2, + }, + ], + }, + { + id: 'urn:TEAM:me-central-1_d:locus', + serviceName: 'locus', + serviceUrls: [ + { + baseUrl: 'https://prod-adxb-message.svc.webex.com/locus/api/v1', + priority: 1, + }, + { + baseUrl: 'https://conv-d.wbx2.com/locus/api/v1', + priority: 2, + }, + ], + }, + { + id: 'urn:TEAM:us-east-2_a:mercury', + serviceName: 'mercury', + serviceUrls: [ + { + baseUrl: 'https://mercury-a.wbx2.com/mercury/api/v1', + priority: 1, + }, + ], + }, + { + id: 'urn:TEAM:me-central-1_d:mercury', + serviceName: 'mercury', + serviceUrls: [ + { + baseUrl: 'https://prod-adxb-message.svc.webex.com/mercury/api/v1', + priority: 1, + }, + { + baseUrl: 'https://conv-d.wbx2.com/mercury/api/v1', + priority: 2, + }, + ], + }, + ], + orgId: '3e0e410f-f83f-4ee4-ac32-12692e99355c', + timestamp: '1745533341', + format: 'U2Cv2', +}; + +export const formattedServiceHostmapV2 = [ + { + id: 'urn:TEAM:us-east-2_a:conversation', + serviceName: 'conversation', + serviceUrls: [ + { + baseUrl: 'https://prod-achm-message.svc.webex.com/conversation/api/v1', + host: 'prod-achm-message.svc.webex.com', + priority: 1, + }, + { + baseUrl: 'https://conv-a.wbx2.com/conversation/api/v1', + host: 'conv-a.wbx2.com', + priority: 2, + }, + ], + }, + { + id: 'urn:TEAM:me-central-1_d:conversation', + serviceName: 'conversation', + serviceUrls: [ + { + baseUrl: 'https://prod-adxb-message.svc.webex.com/conversation/api/v1', + host: 'prod-adxb-message.svc.webex.com', + priority: 1, + }, + { + baseUrl: 'https://conv-d.wbx2.com/conversation/api/v1', + host: 'conv-d.wbx2.com', + priority: 2, + }, + ], + }, + { + id: 'urn:TEAM:us-east-2_a:idbroker', + serviceName: 'idbroker', + serviceUrls: [ + { + baseUrl: 'https://prod-adxb-message.svc.webex.com/idbroker/api/v1', + host: 'prod-adxb-message.svc.webex.com', + priority: 1, + }, + { + baseUrl: 'https://idbroker.webex.com/idb/api/v1', + host: 'idbroker.webex.com', + priority: 2, + }, + ], + }, + { + id: 'urn:TEAM:me-central-1_d:idbroker', + serviceName: 'idbroker', + serviceUrls: [ + { + baseUrl: 'https://prod-adxb-message.svc.webex.com/idbroker/api/v1', + host: 'prod-adxb-message.svc.webex.com', + priority: 1, + }, + { + baseUrl: 'https://conv-d.wbx2.com/idbroker/api/v1', + host: 'conv-d.wbx2.com', + priority: 2, + }, + ], + }, + { + id: 'urn:TEAM:us-east-2_a:locus', + serviceName: 'locus', + serviceUrls: [ + { + baseUrl: 'https://prod-adxb-message.svc.webex.com/locus/api/v1', + host: 'prod-adxb-message.svc.webex.com', + priority: 1, + }, + { + baseUrl: 'https://locus-a.wbx2.com/locus/api/v1', + host: 'locus-a.wbx2.com', + priority: 2, + }, + ], + }, + { + id: 'urn:TEAM:me-central-1_d:locus', + serviceName: 'locus', + serviceUrls: [ + { + baseUrl: 'https://prod-adxb-message.svc.webex.com/locus/api/v1', + host: 'prod-adxb-message.svc.webex.com', + priority: 1, + }, + { + baseUrl: 'https://conv-d.wbx2.com/locus/api/v1', + host: 'conv-d.wbx2.com', + priority: 2, + }, + ], + }, + { + id: 'urn:TEAM:us-east-2_a:mercury', + serviceName: 'mercury', + serviceUrls: [ + { + baseUrl: 'https://mercury-a.wbx2.com/mercury/api/v1', + host: 'mercury-a.wbx2.com', + priority: 1, + }, + ], + }, + { + id: 'urn:TEAM:me-central-1_d:mercury', + serviceName: 'mercury', + serviceUrls: [ + { + baseUrl: 'https://prod-adxb-message.svc.webex.com/mercury/api/v1', + host: 'prod-adxb-message.svc.webex.com', + priority: 1, + }, + { + baseUrl: 'https://conv-d.wbx2.com/mercury/api/v1', + host: 'conv-d.wbx2.com', + priority: 2, + }, + ], + }, +]; diff --git a/packages/@webex/webex-core/test/unit/spec/services-v2/services-v2.js b/packages/@webex/webex-core/test/unit/spec/services-v2/services-v2.js new file mode 100644 index 00000000000..731fd1fa949 --- /dev/null +++ b/packages/@webex/webex-core/test/unit/spec/services-v2/services-v2.js @@ -0,0 +1,564 @@ +/*! + * Copyright (c) 2015-2020 Cisco Systems, Inc. See LICENSE file. + */ + +import {assert} from '@webex/test-helper-chai'; +import MockWebex from '@webex/test-helper-mock-webex'; +import sinon from 'sinon'; +import {ServicesV2} from '@webex/webex-core'; +import {NewMetrics} from '@webex/internal-plugin-metrics'; +import {formattedServiceHostmapV2, serviceHostmapV2} from '../../../fixtures/host-catalog-v2'; + +const waitForAsync = () => + new Promise((resolve) => + setImmediate(() => { + return resolve(); + }) + ); + +/* eslint-disable no-underscore-dangle */ +describe('webex-core', () => { + describe('ServicesV2', () => { + let webex; + let services; + let catalog; + + beforeEach(() => { + webex = new MockWebex({ + children: { + services: ServicesV2, + newMetrics: NewMetrics, + }, + }); + services = webex.internal.services; + catalog = services._getCatalog(); + }); + + // describe('#initialize', () => { + // it('initFailed is false when initialization succeeds and credentials are available', async () => { + // services.listenToOnce = sinon.stub(); + // services.initServiceCatalogs = sinon.stub().returns(Promise.resolve()); + // services.webex.credentials = { + // supertoken: { + // access_token: 'token', + // }, + // }; + + // services.initialize(); + + // // call the onReady callback + // services.listenToOnce.getCall(1).args[2](); + // await waitForAsync(); + + // assert.isFalse(services.initFailed); + // }); + + // it('initFailed is false when initialization succeeds no credentials are available', async () => { + // services.listenToOnce = sinon.stub(); + // services.collectPreauthCatalog = sinon.stub().returns(Promise.resolve()); + + // services.initialize(); + + // // call the onReady callback + // services.listenToOnce.getCall(1).args[2](); + // await waitForAsync(); + + // assert.isFalse(services.initFailed); + // }); + + // it.each([ + // {error: new Error('failed'), expectedMessage: 'failed'}, + // {error: undefined, expectedMessage: undefined}, + // ])( + // 'sets initFailed to true when collectPreauthCatalog errors', + // async ({error, expectedMessage}) => { + // services.collectPreauthCatalog = sinon.stub().callsFake(() => { + // return Promise.reject(error); + // }); + + // services.listenToOnce = sinon.stub(); + // services.logger.error = sinon.stub(); + + // services.initialize(); + + // // call the onReady callback + // services.listenToOnce.getCall(1).args[2](); + + // await waitForAsync(); + + // assert.isTrue(services.initFailed); + // sinon.assert.calledWith( + // services.logger.error, + // `services: failed to init initial services when no credentials available, ${expectedMessage}` + // ); + // } + // ); + + // it.each([ + // {error: new Error('failed'), expectedMessage: 'failed'}, + // {error: undefined, expectedMessage: undefined}, + // ])( + // 'sets initFailed to true when initServiceCatalogs errors', + // async ({error, expectedMessage}) => { + // services.initServiceCatalogs = sinon.stub().callsFake(() => { + // return Promise.reject(error); + // }); + // services.webex.credentials = { + // supertoken: { + // access_token: 'token', + // }, + // }; + + // services.listenToOnce = sinon.stub(); + // services.logger.error = sinon.stub(); + + // services.initialize(); + + // // call the onReady callback + // services.listenToOnce.getCall(1).args[2](); + + // await waitForAsync(); + + // assert.isTrue(services.initFailed); + // sinon.assert.calledWith( + // services.logger.error, + // `services: failed to init initial services when credentials available, ${expectedMessage}` + // ); + // } + // ); + // }); + + // describe('#initServiceCatalogs', () => { + // it('does not set initFailed to true when updateServices succeeds', async () => { + // services.webex.credentials = { + // getOrgId: sinon.stub().returns('orgId'), + // canAuthorize: true, + // }; + + // services.collectPreauthCatalog = sinon.stub().callsFake(() => { + // return Promise.resolve(); + // }); + + // services.updateServices = sinon.stub().callsFake(() => { + // return Promise.resolve(); + // }); + + // services.logger.error = sinon.stub(); + + // await services.initServiceCatalogs(); + + // assert.isFalse(services.initFailed); + + // sinon.assert.calledWith(services.collectPreauthCatalog, {orgId: 'orgId'}); + // sinon.assert.notCalled(services.logger.warn); + // }); + + // it('sets initFailed to true when updateServices errors', async () => { + // const error = new Error('failed'); + + // services.webex.credentials = { + // getOrgId: sinon.stub().returns('orgId'), + // canAuthorize: true, + // }; + + // services.collectPreauthCatalog = sinon.stub().callsFake(() => { + // return Promise.resolve(); + // }); + + // services.updateServices = sinon.stub().callsFake(() => { + // return Promise.reject(error); + // }); + + // services.logger.error = sinon.stub(); + + // await services.initServiceCatalogs(); + + // assert.isTrue(services.initFailed); + + // sinon.assert.calledWith(services.collectPreauthCatalog, {orgId: 'orgId'}); + // sinon.assert.calledWith(services.logger.warn, 'services: cannot retrieve postauth catalog'); + // }); + // }); + + // describe('class members', () => { + // describe('#registries', () => { + // it('should be a weakmap', () => { + // assert.instanceOf(services.registries, WeakMap); + // }); + // }); + + // describe('#states', () => { + // it('should be a weakmap', () => { + // assert.instanceOf(services.states, WeakMap); + // }); + // }); + // }); + + // describe('class methods', () => { + // describe('#getRegistry', () => { + // it('should be a service registry', () => { + // assert.instanceOf(services.getRegistry(), ServiceRegistry); + // }); + // }); + + // describe('#getState', () => { + // it('should be a service state', () => { + // assert.instanceOf(services.getState(), ServiceState); + // }); + // }); + // }); + + // describe('#namespace', () => { + // it('is accurate to plugin name', () => { + // assert.equal(services.namespace, 'Services'); + // }); + // }); + + // describe('#_catalogs', () => { + // it('is a weakmap', () => { + // assert.typeOf(services._catalogs, 'weakmap'); + // }); + // }); + + // describe('#validateDomains', () => { + // it('is a boolean', () => { + // assert.isBoolean(services.validateDomains); + // }); + // }); + + // describe('#initFailed', () => { + // it('is a boolean', () => { + // assert.isFalse(services.initFailed); + // }); + // }); + + // describe('#list()', () => { + // let serviceList; + + // beforeEach(() => { + // serviceList = services.list(); + // }); + + // it('must return an object', () => { + // assert.typeOf(serviceList, 'object'); + // }); + + // it('returned list must be of shape {Record}', () => { + // Object.keys(serviceList).forEach((key) => { + // assert.typeOf(key, 'string'); + // assert.typeOf(serviceList[key], 'string'); + // }); + // }); + // }); + + // describe('#fetchClientRegionInfo', () => { + // beforeEach(() => { + // services.webex.config = { + // services: { + // discovery: { + // sqdiscovery: 'https://test.ciscospark.com/v1/region', + // }, + // }, + // }; + // }); + + // it('successfully resolves with undefined if fetch request failed', () => { + // webex.request = sinon.stub().returns(Promise.reject()); + + // return services.fetchClientRegionInfo().then((r) => { + // assert.isUndefined(r); + // }); + // }); + + // it('successfully resolves with true if fetch request succeeds', () => { + // webex.request = sinon.stub().returns(Promise.resolve({body: true})); + + // return services.fetchClientRegionInfo().then((r) => { + // assert.equal(r, true); + // assert.calledWith(webex.request, { + // uri: 'https://test.ciscospark.com/v1/region', + // addAuthHeader: false, + // headers: {'spark-user-agent': null}, + // timeout: 5000, + // }); + // }); + // }); + // }); + + // describe('#getMeetingPreferences', () => { + // it('Fetch login users information ', async () => { + // const userPreferences = {userPreferences: 'userPreferences'}; + + // webex.request = sinon.stub().returns(Promise.resolve({body: userPreferences})); + + // const res = await services.getMeetingPreferences(); + + // assert.calledWith(webex.request, { + // method: 'GET', + // service: 'hydra', + // resource: 'meetingPreferences', + // }); + // assert.isDefined(res); + // assert.equal(res, userPreferences); + // }); + + // it('Resolve getMeetingPreferences if the api request fails ', async () => { + // webex.request = sinon.stub().returns(Promise.reject()); + + // const res = await services.getMeetingPreferences(); + + // assert.calledWith(webex.request, { + // method: 'GET', + // service: 'hydra', + // resource: 'meetingPreferences', + // }); + // assert.isUndefined(res); + // }); + // }); + + // describe('#updateCatalog', () => { + // it('updates the catalog', async () => { + // const serviceGroup = 'postauth'; + // const hostmap = {hostmap: 'hostmap'}; + + // services._formatReceivedHostmap = sinon.stub().returns({some: 'hostmap'}); + + // catalog.updateServiceUrls = sinon.stub().returns(Promise.resolve({some: 'value'})); + + // const result = await services.updateCatalog(serviceGroup, hostmap); + + // assert.calledWith(services._formatReceivedHostmap, hostmap); + + // assert.calledWith(catalog.updateServiceUrls, serviceGroup, {some: 'hostmap'}); + + // assert.deepEqual(result, {some: 'value'}); + // }); + // }); + + // describe('#_fetchNewServiceHostmap()', () => { + // beforeEach(() => { + // sinon.spy(webex.internal.newMetrics.callDiagnosticLatencies, 'measureLatency'); + // }); + + // afterEach(() => { + // sinon.restore(); + // }); + + // it('checks service request resolves', async () => { + // const mapResponse = 'map response'; + + // sinon.stub(services, '_formatReceivedHostmap').resolves(mapResponse); + // sinon.stub(services, 'request').resolves({}); + + // const mapResult = await services._fetchNewServiceHostmap({from: 'limited'}); + + // assert.deepEqual(mapResult, mapResponse); + + // assert.calledOnceWithExactly(services.request, { + // method: 'GET', + // service: 'u2c', + // resource: '/limited/catalog', + // qs: {format: 'hostmap'}, + // }); + // assert.calledOnceWithExactly( + // webex.internal.newMetrics.callDiagnosticLatencies.measureLatency, + // sinon.match.func, + // 'internal.get.u2c.time' + // ); + // }); + + // it('checks service request rejects', async () => { + // const error = new Error('some error'); + + // sinon.spy(services, '_formatReceivedHostmap'); + // sinon.stub(services, 'request').rejects(error); + + // const promise = services._fetchNewServiceHostmap({from: 'limited'}); + // const rejectedValue = await assert.isRejected(promise); + + // assert.deepEqual(rejectedValue, error); + + // assert.notCalled(services._formatReceivedHostmap); + + // assert.calledOnceWithExactly(services.request, { + // method: 'GET', + // service: 'u2c', + // resource: '/limited/catalog', + // qs: {format: 'hostmap'}, + // }); + // assert.calledOnceWithExactly( + // webex.internal.newMetrics.callDiagnosticLatencies.measureLatency, + // sinon.match.func, + // 'internal.get.u2c.time' + // ); + // }); + // }); + + // describe('replaceHostFromHostmap', () => { + // it('returns the same uri if the hostmap is not set', () => { + // services._hostCatalog = null; + + // const uri = 'http://example.com'; + + // assert.equal(services.replaceHostFromHostmap(uri), uri); + // }); + + // it('returns the same uri if the hostmap does not contain the host', () => { + // services._hostCatalog = { + // 'not-example.com': [ + // { + // host: 'example-1.com', + // ttl: -1, + // priority: 5, + // id: '0:0:0:example', + // }, + // ], + // }; + + // const uri = 'http://example.com'; + + // assert.equal(services.replaceHostFromHostmap(uri), uri); + // }); + + // it('returns the original uri if the hostmap has no hosts for the host', () => { + // services._hostCatalog = { + // 'example.com': [], + // }; + + // const uri = 'http://example.com'; + + // assert.equal(services.replaceHostFromHostmap(uri), uri); + // }); + + // it('returns the replaces the host in the uri with the host from the hostmap', () => { + // services._hostCatalog = { + // 'example.com': [ + // { + // host: 'example-1.com', + // ttl: -1, + // priority: 5, + // id: '0:0:0:example', + // }, + // ], + // }; + + // const uri = 'http://example.com/somepath'; + + // assert.equal(services.replaceHostFromHostmap(uri), 'http://example-1.com/somepath'); + // }); + // }); + + describe('#_formatReceivedHostmap()', () => { + let serviceHostmap; + let formattedHM; + + beforeEach(() => { + serviceHostmap = serviceHostmapV2; + }); + + it('creates a formmatted hostmap that contains the same amount of entries as the original received hostmap', () => { + formattedHM = services._formatReceivedHostmap(serviceHostmap); + + assert( + serviceHostmap.services.length >= formattedHM.length, + 'length is not equal or less than' + ); + }); + + it('has all keys in host map hosts', () => { + formattedHM = services._formatReceivedHostmap(serviceHostmap); + + formattedHM.forEach((service) => { + assert.hasAllKeys( + service, + ['id', 'serviceName', 'serviceUrls'], + `${service.serviceName} has an invalid host shape` + ); + service.serviceUrls.forEach((serviceUrl) => { + assert.hasAllKeys( + serviceUrl, + ['host', 'baseUrl', 'priority'], + `${service.serviceName} has an invalid host shape` + ); + }); + }); + }); + + it('creates a formmated host map containing all received host map service entries', () => { + formattedHM = services._formatReceivedHostmap(serviceHostmap); + + formattedHM.forEach((service) => { + const foundServiceKey = Object.keys(serviceHostmap.activeServices).find( + (key) => service.serviceName === key + ); + + assert.isDefined(foundServiceKey); + }); + }); + + it('creates the expected formatted host map', () => { + formattedHM = services._formatReceivedHostmap(serviceHostmap); + + assert.deepEqual(formattedHM, formattedServiceHostmapV2); + }); + + it('has hostCatalog updated', () => { + services._services = [ + {id: 'urn:TEAM:us-east-2_a:conversation'}, + {id: 'test-left-over-services'}, + ]; + services._formatReceivedHostmap(serviceHostmap); + + assert.deepStrictEqual(services._services, [ + ...serviceHostmapV2.services, + {id: 'test-left-over-services'}, + ]); + }); + }); + + // describe('#updateCredentialsConfig()', () => { + // // updateCredentialsConfig must remove `/` if exist. so expected serviceList must be. + // const expectedServiceList = { + // idbroker: 'https://idbroker.webex.com', + // identity: 'https://identity.webex.com', + // }; + + // beforeEach(async () => { + // const servicesList = { + // idbroker: 'https://idbroker.webex.com', + // identity: 'https://identity.webex.com/', + // }; + + // catalog.list = sinon.stub().returns(servicesList); + // await services.updateCredentialsConfig(); + // }); + + // it('sets the idbroker url properly when trailing slash is not present', () => { + // assert.equal(webex.config.credentials.idbroker.url, expectedServiceList.idbroker); + // }); + + // it('sets the identity url properly when a trailing slash is present', () => { + // assert.equal(webex.config.credentials.identity.url, expectedServiceList.identity); + // }); + + // it('sets the authorize url properly when authorization string is not provided', () => { + // assert.equal( + // webex.config.credentials.authorizeUrl, + // `${expectedServiceList.idbroker}/idb/oauth2/v1/authorize` + // ); + // }); + + // it('should retain the authorize url property when authorization string is provided', () => { + // const authUrl = 'http://example-auth-url.com/resource'; + + // webex.config.credentials.authorizationString = authUrl; + // webex.config.credentials.authorizeUrl = authUrl; + + // services.updateCredentialsConfig(); + + // assert.equal(webex.config.credentials.authorizeUrl, authUrl); + // }); + // }); + }); +}); +/* eslint-enable no-underscore-dangle */ diff --git a/yarn.lock b/yarn.lock index b4f9ce60cee..abc5b26c4f0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8361,7 +8361,7 @@ __metadata: isomorphic-webcrypto: ^2.3.8 lodash: ^4.17.21 node-jose: ^2.2.0 - node-kms: ^0.4.0 + node-kms: ^0.4.1 node-scr: ^0.3.0 pkijs: ^2.1.84 prettier: ^2.7.1 @@ -9118,7 +9118,7 @@ __metadata: jest-junit: 13.0.0 lodash: ^4.17.21 prettier: 2.5.1 - typedoc: 0.23.26 + typedoc: ^0.25.0 typescript: 4.9.5 languageName: unknown linkType: soft @@ -24670,7 +24670,7 @@ __metadata: languageName: node linkType: hard -"marked@npm:^4.2.12": +"marked@npm:^4.2.12, marked@npm:^4.3.0": version: 4.3.0 resolution: "marked@npm:4.3.0" bin: @@ -25548,7 +25548,7 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^9.0.0, minimatch@npm:^9.0.4, minimatch@npm:^9.0.5": +"minimatch@npm:^9.0.0, minimatch@npm:^9.0.3, minimatch@npm:^9.0.4, minimatch@npm:^9.0.5": version: 9.0.5 resolution: "minimatch@npm:9.0.5" dependencies: @@ -26282,16 +26282,16 @@ __metadata: languageName: node linkType: hard -"node-kms@npm:^0.4.0": - version: 0.4.0 - resolution: "node-kms@npm:0.4.0" +"node-kms@npm:^0.4.1": + version: 0.4.1 + resolution: "node-kms@npm:0.4.1" dependencies: es6-promise: ^2.0.1 lodash.clone: ^3.0.2 lodash.clonedeep: ^3.0.1 - node-jose: ^2.0.0 + node-jose: ^2.2.0 uuid: ^2.0.1 - checksum: 00d27958ec84c1dac786ddcaccf227e619bbd201943467620a18df4128d1e79f3a69742f9bdf1fd9a4cafe1e3a554b99b353c718c55063bf75f71b2b66af8fad + checksum: e68542feac20ef35e575811c4ad6255b41ae5888c9c5a5b68604fc964c956a5b9f478fe5ee587f172d697e57121b78cecd6101a0dc007bd627a9c4d23dd5707f languageName: node linkType: hard @@ -30420,6 +30420,18 @@ __metadata: languageName: node linkType: hard +"shiki@npm:^0.14.7": + version: 0.14.7 + resolution: "shiki@npm:0.14.7" + dependencies: + ansi-sequence-parser: ^1.1.0 + jsonc-parser: ^3.2.0 + vscode-oniguruma: ^1.7.0 + vscode-textmate: ^8.0.0 + checksum: 2aec3b3519df977c4391df9e1825cb496e9a4d7e11395f05a0da77e4fa2f7c3d9d6e6ee94029ac699533017f2b25637ee68f6d39f05f311535c2704d0329b520 + languageName: node + linkType: hard + "side-channel@npm:^1.0.4": version: 1.0.4 resolution: "side-channel@npm:1.0.4" @@ -32722,6 +32734,22 @@ __metadata: languageName: node linkType: hard +"typedoc@npm:^0.25.0": + version: 0.25.13 + resolution: "typedoc@npm:0.25.13" + dependencies: + lunr: ^2.3.9 + marked: ^4.3.0 + minimatch: ^9.0.3 + shiki: ^0.14.7 + peerDependencies: + typescript: 4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x + bin: + typedoc: bin/typedoc + checksum: 703d1f48137300b0ef3df1998a25ae745db3ca0b126f8dd1f7262918f11243a94d24dfc916cdba2baeb5a7d85d5a94faac811caf7f4fa6b7d07144dc02f7639f + languageName: node + linkType: hard + "typescript-eslint@npm:^8.8.1": version: 8.8.1 resolution: "typescript-eslint@npm:8.8.1" From 4fdf2a3876e84324ea795afe88e64c2037c64ade Mon Sep 17 00:00:00 2001 From: Adhwaith Menon <111346225+adhmenon@users.noreply.github.com> Date: Mon, 23 Jun 2025 14:38:18 +0530 Subject: [PATCH 4/8] feat(plugin-cc): refactored-voice-class (#4335) --- docs/samples/contact-center/app.js | 231 +++---- packages/@webex/plugin-cc/src/cc.ts | 14 +- packages/@webex/plugin-cc/src/constants.ts | 5 + .../plugin-cc/src/services/task/Task.ts | 37 +- .../src/services/task/TaskManager.ts | 19 +- .../plugin-cc/src/services/task/types.ts | 50 +- .../src/services/task/voice/Voice.ts | 599 ++++++++++++++++-- .../src/services/task/voice/WebRTC.ts | 2 +- .../test/unit/spec/services/task/Task.ts | 20 +- .../unit/spec/services/task/TaskManager.ts | 3 +- .../spec/services/task/digital/Digital.ts | 4 +- .../unit/spec/services/task/voice/Voice.ts | 374 ++++++++++- packages/@webex/plugin-cc/typedoc.md | 2 +- 13 files changed, 1118 insertions(+), 242 deletions(-) diff --git a/docs/samples/contact-center/app.js b/docs/samples/contact-center/app.js index b3d8c52ecfe..8ee9232bc91 100644 --- a/docs/samples/contact-center/app.js +++ b/docs/samples/contact-center/app.js @@ -214,11 +214,20 @@ const taskEvents = new CustomEvent('task:incoming', { function updateButtonsPostEndCall() { disableAllCallControls(); - if(currentTask) { - wrapupElm.disabled = false; - wrapupCodesDropdownElm.disabled = false; - } else { + + if (currentTask) { + setUIControls(currentTask); + + const wctrl = currentTask.taskUiControls.wrapup; + wrapupElm.style.display = wctrl.visible ? 'inline-block' : 'none'; + wrapupElm.disabled = !wctrl.enabled; + wrapupCodesDropdownElm.style.display = wctrl.visible ? 'inline-block' : 'none'; + wrapupCodesDropdownElm.disabled = !wctrl.enabled; + } + else { + wrapupElm.style.display = 'none'; wrapupElm.disabled = true; + wrapupCodesDropdownElm.style.display = 'none'; wrapupCodesDropdownElm.disabled = true; } } @@ -231,22 +240,6 @@ function closeConsultDialog() { initiateConsultDialog.close(); } -function showConsultButton() { - consultTabBtn.style.display = 'inline-block'; -} - -function hideConsultButton() { - consultTabBtn.style.display = 'none'; -} - -function showEndConsultButton() { - endConsultBtn.style.display = 'inline-block'; -} - -function hideEndConsultButton() { - endConsultBtn.style.display = 'none'; -} - function toggleTransferOptions() { // toggle display of transfer options isTransferOptionsShown = !isTransferOptionsShown; @@ -370,7 +363,11 @@ async function onTransferTypeSelectionChanged() { // Function to initiate consult async function initiateConsult() { const destinationType = destinationTypeDropdown.value; + // Persist selected consult destinationType across reload + sessionStorage.setItem('savedConsultTransferType', destinationType); const consultDestination = consultDestinationInput.value; + // Persist selected consult destination across reload + sessionStorage.setItem('savedConsultDestination', consultDestination); if (!consultDestination) { alert('Please enter a destination'); @@ -391,11 +388,11 @@ async function initiateConsult() { try { await currentTask.consult(consultPayload); + setUIControls(currentTask); 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); + setUIControls(currentTask); alert('Failed to initiate consult'); } } @@ -416,16 +413,22 @@ async function handleQueueConsult(consultPayload) { alert('Failed to initiate queue consult'); // Restore UI state refreshUIPostConsult(); + setUIControls(currentTask); currentConsultQueueId = null; } } -// Updates UI state for queue consult initiation +// Updates UI state for queue consult initiation (have to do this as queue consult is initially UI) function updateConsultUI() { disableCallControlPostConsult(); disableTransferControls(); - hideConsultButton(); - showEndConsultButton(); + endElm.style.display = 'none'; + endElm.disabled = true; + endConsultBtn.style.display = 'inline-block'; + endConsultBtn.disabled = false; + consultTabBtn.style.display = 'none'; + consultTransferBtn.style.display = 'inline-block'; + consultTransferBtn.disabled = true; } // Function to initiate transfer @@ -456,8 +459,8 @@ async function initiateTransfer() { // Function to initiate consult transfer async function initiateConsultTransfer() { - const destinationType = destinationTypeDropdown.value; - const consultDestination = consultDestinationInput.value; + const destinationType = destinationTypeDropdown.value ?? sessionStorage.getItem('savedConsultTransferType'); + const consultDestination = consultDestinationInput.value ?? sessionStorage.getItem('savedConsultDestination'); if (!consultDestination) { alert('Please enter a destination'); @@ -470,7 +473,7 @@ async function initiateConsultTransfer() { }; try { - await currentTask.consultTransfer(consultTransferPayload); + await currentTask.transfer(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 @@ -497,8 +500,7 @@ async function endConsult() { try { await currentTask.endConsult(consultEndPayload); console.log('Consult ended successfully'); - hideEndConsultButton(); - showConsultButton(); + setUIControls(currentTask); } catch (error) { console.error('Failed to end consult', error); alert('Failed to end consult'); @@ -579,8 +581,6 @@ function enableCallControlPostConsult() { function refreshUIPostConsult() { enableCallControlPostConsult(); enableTransferControls(); - showConsultButton(); - hideEndConsultButton(); } // Register task listeners @@ -589,6 +589,10 @@ function registerTaskListeners(task) { updateTaskList(); // Update the task list UI to have latest tasks console.info('Call has been accepted for task: ', task.data.interactionId); handleTaskSelect(task); + if (task.data.interaction.mediaType === 'telephony') { + setUIControls(task); + incomingDetailsElm.innerText = ''; + } }); task.on('task:media', (track) => { document.getElementById('remote-audio').srcObject = new MediaStream([track]); @@ -603,7 +607,7 @@ function registerTaskListeners(task) { } else { console.info('Call ended successfully'); - updateButtonsPostEndCall(); + setUIControls(task); } updateTaskList(); // Update the task list UI to have latest tasks handleTaskSelect(task); @@ -620,6 +624,9 @@ function registerTaskListeners(task) { // Consult flows task.on('task:consultCreated', (task) => { console.info('Consult created'); + if (currentTask.data.interactionId === task.data.interactionId) { + setUIControls(task); + } }); task.on('task:offerConsult', (task) => { @@ -628,18 +635,14 @@ function registerTaskListeners(task) { 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 + // use UI controls for consult acceptance + setUIControls(task); } }); 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 + setUIControls(task); } }); @@ -647,8 +650,7 @@ function registerTaskListeners(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(); + setUIControls(task); } }); @@ -657,26 +659,15 @@ function registerTaskListeners(task) { // 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(); + setUIControls(task); } }); 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; + setUIControls(task); currentConsultQueueId = null; - if(task.data.isConsulted) { - updateButtonsPostEndCall(); + if (task.data.isConsulted) { incomingDetailsElm.innerText = ''; task = undefined; } @@ -687,6 +678,10 @@ function registerTaskListeners(task) { console.info('Task is rejected with reason:', reason); showAgentStatePopup(reason); }); + + task.on('task:wrappedup', (task) => { + setUIControls(task); + }); } function disableAllCallControls() { @@ -700,30 +695,54 @@ function disableAllCallControls() { pauseResumeRecordingElm.disabled = true; } +function setUIControls(task) { + const ctrls = task.taskUiControls; + const btnMap = { + accept: answerElm, + decline: declineElm, + hold: holdResumeElm, + mute: muteElm, + transfer: transferElm, + consult: consultTabBtn, + consultTransfer: consultTransferBtn, + recording: pauseResumeRecordingElm, + end: endElm, + endConsult: endConsultBtn, + wrapup: wrapupElm + }; + + Object.entries(btnMap).forEach(([key, el]) => { + const c = ctrls[key]; + if (!c || !el) return; + el.style.display = c.visible ? 'inline-block' : 'none'; + el.disabled = !c.enabled; + }); + + // wrapup‐codes dropdown mirrors wrapup control + wrapupCodesDropdownElm.style.display = ctrls.wrapup.visible ? 'inline-block' : 'none'; + wrapupCodesDropdownElm.disabled = !ctrls.wrapup.enabled; +} + function updateCallControlUI(task) { const { data } = task; - const { interaction, mediaResourceId } = data; - const { - isTerminated, - media, - participants, - callProcessingDetails - } = interaction; - + const { interaction, participants } = data.interaction + ? { interaction: data.interaction, participants: data.interaction.participants, callProcessingDetails: data.interaction.callProcessingDetails } + : {}; + const hasParticipants = participants && Object.keys(participants).length > 1; + const isNew = data.interaction.state === 'new'; + const digitalChannels = ['chat', 'email', 'social']; - if (task.data.wrapUpRequired) { + if (data.wrapUpRequired) { updateButtonsPostEndCall(); return; } + wrapupElm.disabled = true; wrapupCodesDropdownElm.disabled = true; - const hasParticipants = Object.keys(participants).length > 1; - const isNew = task.data.interaction.state === 'new'; - const digitalChannels = ['chat', 'email', 'social']; if (isNew) { disableAllCallControls(); - } else if (digitalChannels.includes(task.data.interaction.mediaType)) { + } else if (digitalChannels.includes(interaction.mediaType)) { holdResumeElm.disabled = true; muteElm.disabled = true; pauseResumeRecordingElm.disabled = true; @@ -732,31 +751,17 @@ function updateCallControlUI(task) { transferElm.disabled = false; endElm.disabled = !hasParticipants; pauseResumeRecordingElm.disabled = true; - } else if (task?.data?.interaction?.mediaType === 'telephony') { - // hold/resume call - const isHold = media && media[mediaResourceId] && media[mediaResourceId].isHold; - holdResumeElm.disabled = isTerminated; - holdResumeElm.innerText = isHold ? 'Resume' : 'Hold'; - transferElm.disabled = false; - 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 - pauseResumeRecordingElm.innerText = isPaused === 'true' ? 'Resume Recording' : 'Pause Recording'; - } - - // end consult, consult transfer buttons + } + else if (interaction.mediaType === 'telephony') { + // apply UI controls driven by task.taskUiControls + setUIControls(task); + + // leave consult‐to‐queue UI unchanged const { consultMediaResourceId, destAgentId, destinationType } = data; if (consultMediaResourceId && destAgentId && destinationType) { const destination = participants[destAgentId]; destinationTypeDropdown.value = destinationType; - consultDestinationInput.value = destination.dn; + consultDestinationInput.value = destination.dn; consultTabBtn.style.display = 'none'; endConsultBtn.style.display = 'inline-block'; @@ -939,6 +944,7 @@ function register() { webex.cc.on('task:hydrate', (currentTask) => { handleTaskHydrate(currentTask); + registerTaskListeners(currentTask); }); webex.cc.on('agent:stateChange', (data) => { @@ -1041,6 +1047,13 @@ deregisterBtn.addEventListener('click', doDeRegister); function handleTaskHydrate(task) { currentTask = task; + // Restore last consult-transfer destinationType after reload + const savedType = sessionStorage.getItem('savedConsultTransferType'); + const savedDest = sessionStorage.getItem('savedConsultDestination'); + if (savedType && savedDest) { + destinationTypeDropdown.value = savedType; + consultDestinationInput.value = savedDest; + } if (!currentTask || !currentTask.data || !currentTask.data.interaction) { console.error('task:hydrate --> No task data found.'); @@ -1263,7 +1276,15 @@ incomingCallListener.addEventListener('task:incoming', (event) => { taskId = event.detail.task.data.interactionId; registerTaskListeners(currentTask); - enableAnswerDeclineButtons(currentTask); + + if (currentTask.data.interaction.mediaType === 'telephony' && webex.cc.taskManager.webCallingService.loginOption !== 'BROWSER') { + setUIControls(currentTask); + } + else { + enableAnswerDeclineButtons(currentTask); + } + + incomingDetailsElm.innerText = `Call from ${currentTask.data.interaction.callAssociatedDetails?.ani}`; }); async function answer() { @@ -1341,27 +1362,18 @@ function expandAll() { } function holdResumeCall() { - if (holdResumeElm.innerText === 'Hold') { - 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'; + const isHeld = holdResumeElm.innerText === 'Hold'; + holdResumeElm.disabled = true; + currentTask.holdResume() + .then(() => { + console.info(`Call ${isHeld ? 'held' : 'resumed'} successfully`); + holdResumeElm.innerText = isHeld ? 'Resume' : 'Hold'; holdResumeElm.disabled = false; - }).catch((error) => { - console.error('Failed to resume the call', error); + }) + .catch((error) => { + console.error(`Failed to ${isHeld ? 'hold' : 'resume'} the call`, error); holdResumeElm.disabled = false; }); - } } function muteUnmute() { @@ -1407,7 +1419,6 @@ function endCall() { endElm.disabled = true; currentTask.end().then(() => { console.log('task ended successfully by agent'); - updateButtonsPostEndCall(); updateTaskList(); updateUnregisterButtonState(); }).catch((error) => { diff --git a/packages/@webex/plugin-cc/src/cc.ts b/packages/@webex/plugin-cc/src/cc.ts index ca54eeff619..2e7e40eb8cf 100644 --- a/packages/@webex/plugin-cc/src/cc.ts +++ b/packages/@webex/plugin-cc/src/cc.ts @@ -221,13 +221,6 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter const resp = await this.connectWebsocket(); // Ensure 'dn' is always populated from 'defaultDn' resp.dn = resp.defaultDn; - const configFlags: ConfigFlags = { - isEndCallEnabled: this.agentConfig.isEndCallEnabled, - isEndConsultEnabled: this.agentConfig.isEndConsultEnabled, - webRtcEnabled: this.agentConfig.webRtcEnabled, - autoWrapup: this.agentConfig.wrapUpData?.wrapUpProps?.autoWrapup ?? false, - }; - this.taskManager.setConfigFlags(configFlags); this.metricsManager.trackEvent( METRIC_EVENT_NAMES.WEBSOCKET_REGISTER_SUCCESS, { @@ -424,6 +417,13 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter const agentId = data.agentId; const orgId = this.$webex.credentials.getOrgId(); this.agentConfig = await this.services.config.getAgentConfig(orgId, agentId); + const configFlags: ConfigFlags = { + isEndCallEnabled: this.agentConfig.isEndCallEnabled, + isEndConsultEnabled: this.agentConfig.isEndConsultEnabled, + webRtcEnabled: this.agentConfig.webRtcEnabled, + autoWrapup: this.agentConfig.wrapUpData?.wrapUpProps?.autoWrapup ?? false, + }; + this.taskManager.setConfigFlags(configFlags); LoggerProxy.log(`Agent config is fetched successfully`, { module: CC_FILE, method: METHODS.CONNECT_WEBSOCKET, diff --git a/packages/@webex/plugin-cc/src/constants.ts b/packages/@webex/plugin-cc/src/constants.ts index d4c3dfaa817..fea29b41306 100644 --- a/packages/@webex/plugin-cc/src/constants.ts +++ b/packages/@webex/plugin-cc/src/constants.ts @@ -48,4 +48,9 @@ export const METHODS = { HANDLE_INCOMING_TASK: 'handleIncomingTask', HANDLE_TASK_HYDRATE: 'handleTaskHydrate', INCOMING_TASK_LISTENER: 'incomingTaskListener', + HOLD_RESUME: 'holdResume', + ACCEPT: 'accept', + REJECT: 'reject', + TRANSFER_CALL: 'transferCall', + COMPLETE_TRANSFER: 'completeTransfer', }; diff --git a/packages/@webex/plugin-cc/src/services/task/Task.ts b/packages/@webex/plugin-cc/src/services/task/Task.ts index 70988763f21..4a783f4c7fc 100644 --- a/packages/@webex/plugin-cc/src/services/task/Task.ts +++ b/packages/@webex/plugin-cc/src/services/task/Task.ts @@ -48,12 +48,12 @@ export default abstract class Task extends EventEmitter implements ITask { private initialiseUIControls() { this.taskUiControls = { - accept: new TaskButtonControl(true, true), - decline: new TaskButtonControl(true, true), + accept: new TaskButtonControl(false, false), + decline: new TaskButtonControl(false, false), hold: new TaskButtonControl(false, false), mute: new TaskButtonControl(false, false), - end: new TaskButtonControl(true, true), - transfer: new TaskButtonControl(true, true), + end: new TaskButtonControl(false, false), + transfer: new TaskButtonControl(false, false), consult: new TaskButtonControl(false, false), consultTransfer: new TaskButtonControl(false, false), endConsult: new TaskButtonControl(false, false), @@ -68,6 +68,35 @@ export default abstract class Task extends EventEmitter implements ITask { */ protected setUIControls() {} + /** + * + * @param methodName - The name of the method that is unsupported + * @throws Error + */ + protected unsupportedMethodError(methodName: string) { + LoggerProxy.error(`Unsupported operation`, { + module: 'TASK', + method: methodName, + }); + throw new Error(`Unsupported operation: ${methodName}`); + } + + /** + * Apply visibility & enabled flags in one go. + * Usage: updateTaskUiControls({ hold: [true,true], end: [false,true] }) + */ + protected updateTaskUiControls( + config: Partial> + ): void { + Object.entries(config).forEach(([k, [vis, en]]) => { + const ctl = this.taskUiControls[k as keyof typeof this.taskUiControls]; + if (ctl) { + ctl.setVisiblity(vis); + ctl.setEnabled(en); + } + }); + } + /** * This method is used to update the task data. * @param updatedData - TaskData diff --git a/packages/@webex/plugin-cc/src/services/task/TaskManager.ts b/packages/@webex/plugin-cc/src/services/task/TaskManager.ts index 9d7c1ec6713..99520f254ca 100644 --- a/packages/@webex/plugin-cc/src/services/task/TaskManager.ts +++ b/packages/@webex/plugin-cc/src/services/task/TaskManager.ts @@ -95,7 +95,18 @@ export default class TaskManager extends EventEmitter { }); switch (payload.data.type) { case CC_EVENTS.AGENT_CONTACT: - this.taskCollection[payload.data.interactionId] = task; + if (!task) { + // Re-create task if it does not exist + // This can happen when the task is created after the event is received (multi session) + task = TaskFactory.createTask( + this.contact, + this.webCallingService, + {...payload.data, isConsulted: false}, + this.configFlags + ); + this.taskCollection[payload.data.interactionId] = task; + } + this.updateTaskData(task, payload.data); this.emit(TASK_EVENTS.TASK_HYDRATE, task); break; @@ -238,7 +249,11 @@ export default class TaskManager extends EventEmitter { task.emit(TASK_EVENTS.TASK_CONSULT_QUEUE_CANCELLED, task); break; case CC_EVENTS.AGENT_WRAPUP: - this.updateTaskData(task, payload.data); + this.updateTaskData(task, { + ...payload.data, + wrapUpRequired: true, + }); + task.emit(TASK_EVENTS.TASK_END, task); break; case CC_EVENTS.AGENT_WRAPPEDUP: this.removeTaskFromCollection(task); diff --git a/packages/@webex/plugin-cc/src/services/task/types.ts b/packages/@webex/plugin-cc/src/services/task/types.ts index 4b95e55e011..714e72daac6 100644 --- a/packages/@webex/plugin-cc/src/services/task/types.ts +++ b/packages/@webex/plugin-cc/src/services/task/types.ts @@ -1163,23 +1163,15 @@ export interface ITask extends EventEmitter { */ export interface IVoice extends ITask { /** - * This is used to hold the task. + * This is used to hold or resume the task. * @returns Promise * @example * ``` - * task.hold(); + * task.holdResume(); * ``` */ - hold(): Promise; - /** - * This is used to resume the task. - * @returns Promise - * @example - * ``` - * task.resume(); - * ``` - */ - resume(): Promise; + holdResume(): Promise; + /** * This is used to consult the task. * @param consultPayload @@ -1244,31 +1236,25 @@ export class TaskButtonControl { } /** - * Shows the button control. - */ - show() { - this.visible = true; - } - - /** - * Hides the button control. - */ - hide() { - this.visible = false; - } - - /** - * Enables the button control. + * + * @param visible - Indicates whether the button control is visible or not. + * + * This method sets the visibility. + * If `visible` is true, the button will be shown; */ - enable() { - this.enabled = true; + setVisiblity(visible: boolean) { + this.visible = visible; } /** - * Disables the button control. + * + * @param enabled - Indicates whether the button control is enabled or not. + * + * This method sets the enabled state of the button control. + * If `enabled` is true, the button will be enabled; otherwise, it will be disabled. */ - disable() { - this.enabled = false; + setEnabled(enabled: boolean) { + this.enabled = enabled; } } diff --git a/packages/@webex/plugin-cc/src/services/task/voice/Voice.ts b/packages/@webex/plugin-cc/src/services/task/voice/Voice.ts index da894b0d2c2..d11b810f60a 100644 --- a/packages/@webex/plugin-cc/src/services/task/voice/Voice.ts +++ b/packages/@webex/plugin-cc/src/services/task/voice/Voice.ts @@ -1,9 +1,22 @@ -import {CC_FILE} from '../../../constants'; +import {CC_FILE, METHODS} from '../../../constants'; import {getErrorDetails} from '../../core/Utils'; import routingContact from '../contact'; -import {ConsultPayload, ResumeRecordingPayload, TaskResponse, IVoice, TaskData} from '../types'; +import { + ConsultPayload, + ConsultEndPayload, + ResumeRecordingPayload, + TaskData, + TaskResponse, + IVoice, + TransferPayLoad, + ConsultTransferPayLoad, + CONSULT_TRANSFER_DESTINATION_TYPE, +} from '../types'; import Task from '../Task'; +import {CC_EVENTS} from '../../config/types'; import LoggerProxy from '../../../logger-proxy'; +import MetricsManager from '../../../metrics/MetricsManager'; +import {METRIC_EVENT_NAMES} from '../../../metrics/constants'; export default class Voice extends Task implements IVoice { private isEndCallEnabled: boolean; @@ -20,15 +33,210 @@ export default class Voice extends Task implements IVoice { this.isEndConsultEnabled = callOptions.isEndConsultEnabled ?? true; } - protected setUIControls(): void { - // if profile disables end-call, always hide the end-call button - if (!this.isEndCallEnabled) { - this.taskUiControls.end.hide(); + private applyConsultingControls(): void { + this.updateTaskUiControls({ + hold: [false, false], + transfer: [false, false], + consult: [false, false], + recording: [true, false], + }); + + if (!this.data.isConsulted) { + this.updateTaskUiControls({ + consultTransfer: [true, true], + endConsult: [true, true], + end: [this.isEndCallEnabled, false], + }); + } else { + this.updateTaskUiControls({endConsult: [this.isEndConsultEnabled, this.isEndConsultEnabled]}); } + } + + protected setUIControls(): void { + const eventType = this.data.type; + + switch (eventType) { + case CC_EVENTS.AGENT_CONTACT_ASSIGNED: + this.updateTaskUiControls({ + accept: [false, false], + decline: [false, false], + hold: [true, true], + transfer: [true, true], + consult: [true, true], + recording: [true, true], + end: [this.isEndCallEnabled, this.isEndCallEnabled], + endConsult: [false, false], + wrapup: [false, false], + }); + break; + + case CC_EVENTS.AGENT_WRAPUP: + case CC_EVENTS.AGENT_CONTACT_UNASSIGNED: + this.updateTaskUiControls({ + consultTransfer: [false, false], + recording: [false, false], + end: [false, false], + endConsult: [false, false], + hold: [false, false], + transfer: [false, false], + consult: [false, false], + wrapup: [true, true], + }); + break; + + case CC_EVENTS.CONTACT_ENDED: + case CC_EVENTS.AGENT_INVITE_FAILED: + this.updateTaskUiControls({ + hold: [false, false], + transfer: [false, false], + consult: [false, false], + consultTransfer: [false, false], + recording: [false, false], + end: [false, false], + endConsult: [false, false], + }); + if (this.data.interaction.state !== 'new') { + this.updateTaskUiControls({wrapup: [true, true]}); + } + break; + + case CC_EVENTS.AGENT_CONTACT_HELD: + this.updateTaskUiControls({ + hold: [true, true], + transfer: [true, true], + consult: [true, true], + recording: [true, true], + end: [this.isEndCallEnabled, false], + }); + break; + + case CC_EVENTS.AGENT_CONTACT_UNHELD: + this.updateTaskUiControls({ + hold: [true, true], + transfer: [true, true], + consult: [true, true], + recording: [true, true], + end: [this.isEndCallEnabled, true], + }); + break; + + case CC_EVENTS.AGENT_VTEAM_TRANSFERRED: + this.updateTaskUiControls({ + hold: [false, false], + transfer: [false, false], + consult: [false, false], + consultTransfer: [false, false], + recording: [false, false], + end: [false, false], + wrapup: [true, true], + }); + break; - // if profile disables end-consult, always hide the consult-end button - if (!this.isEndConsultEnabled) { - this.taskUiControls.endConsult.hide(); + case CC_EVENTS.AGENT_CTQ_CANCEL_FAILED: + this.updateTaskUiControls({ + hold: [true, true], + transfer: [true, true], + consult: [true, true], + recording: [true, true], + end: [this.isEndCallEnabled, true], + }); + break; + + case CC_EVENTS.AGENT_CONSULT_CREATED: + this.updateTaskUiControls({ + hold: [false, false], + consult: [false, false], + transfer: [true, false], + end: [this.isEndCallEnabled, false], + consultTransfer: [true, false], + recording: [true, false], + endConsult: [true, true], + }); + break; + + case CC_EVENTS.AGENT_OFFER_CONSULT: + this.updateTaskUiControls({ + endConsult: [this.isEndConsultEnabled, this.isEndConsultEnabled], + }); + break; + + case CC_EVENTS.AGENT_CONSULTING: + if (!this.data.isConsulted) { + this.updateTaskUiControls({ + hold: [false, false], + transfer: [true, false], + consult: [false, false], + consultTransfer: [true, true], + recording: [true, false], + endConsult: [true, true], + end: [this.isEndCallEnabled, false], + }); + } else { + this.updateTaskUiControls({ + endConsult: [this.isEndConsultEnabled, this.isEndConsultEnabled], + }); + } + break; + + case CC_EVENTS.AGENT_CONSULT_FAILED: + case CC_EVENTS.AGENT_CONSULT_ENDED: + if (!this.data.isConsulted) { + this.updateTaskUiControls({ + hold: [true, true], + transfer: [true, true], + consult: [true, true], + recording: [true, true], + end: [this.isEndCallEnabled, this.isEndCallEnabled], + consultTransfer: [false, false], + endConsult: [false, false], + wrapup: [false, false], + }); + } else { + this.updateTaskUiControls({ + endConsult: [false, false], + }); + } + break; + + case CC_EVENTS.AGENT_CTQ_CANCELLED: + this.updateTaskUiControls({ + hold: [true, true], + transfer: [true, true], + consult: [true, true], + recording: [true, true], + end: [this.isEndCallEnabled, this.isEndCallEnabled], + consultTransfer: [false, false], + endConsult: [false, false], + wrapup: [false, false], + }); + break; + + case CC_EVENTS.AGENT_CONTACT: + if (this.data.interaction.isTerminated) { + this.updateTaskUiControls({ + hold: [false, false], + transfer: [false, false], + consult: [false, false], + consultTransfer: [false, false], + recording: [false, false], + end: [false, false], + wrapup: [true, true], + }); + } else if (this.data.interaction.state === 'connected' && !this.data.isConsulted) { + this.updateTaskUiControls({ + hold: [true, true], + transfer: [true, true], + consult: [true, true], + recording: [true, true], + end: [this.isEndCallEnabled, this.isEndCallEnabled], + }); + } else if (this.data.interaction.state === 'consulting') { + this.applyConsultingControls(); + } + break; + + default: + break; } } @@ -39,55 +247,112 @@ export default class Voice extends Task implements IVoice { * @throws Error */ public async accept(): Promise { - LoggerProxy.error('Unsupported operation: accept() in Voice object'); - throw new Error('Unsupported operation: accept() in Voice class'); + super.unsupportedMethodError(METHODS.ACCEPT); } /** - * This is used to hold the task. + * This method is used to decline the task. + * It is expected to be overridden by child classes. * @returns Promise * @throws Error - * @example - * ```typescript - * task.hold().then(()=>{}).catch(()=>{}) - * ``` - * */ - public async hold(): Promise { - try { - const response = await this.contact.hold({ - interactionId: this.data.interactionId, - data: {mediaResourceId: this.data.mediaResourceId}, - }); - - return response; - } catch (error) { - const {error: detailedError} = getErrorDetails(error, 'hold', CC_FILE); - throw detailedError; - } + */ + public async decline(): Promise { + super.unsupportedMethodError(METHODS.REJECT); } /** - * This is used to resume the task. + * This is used to hold or resume the task. + * @param isHeld: boolean - true to hold the task, false to resume it * @returns Promise * @throws Error * @example * ```typescript - * task.resume().then(()=>{}).catch(()=>{}) + * task.holdResume(isHeld: true).then(()=>{}).catch(()=>{}) * ``` - */ - public async resume(): Promise { - try { - const {mainInteractionId} = this.data.interaction; - const {mediaResourceId} = this.data.interaction.media[mainInteractionId]; + * */ + public async holdResume(): Promise { + /* + Determine if the task is being held or resumed based on the media resource state + If the media resource is not found, default to resuming the task + */ + const shouldHold = !this.data.interaction.media[this.data.mediaResourceId].isHold; - const response = await this.contact.unHold({ - interactionId: this.data.interactionId, - data: {mediaResourceId}, - }); + LoggerProxy.info(`${shouldHold ? 'Holding' : 'Resuming'} task`, { + module: CC_FILE, + method: METHODS.HOLD_RESUME, + interactionId: this.data.interactionId, + }); + const [successEvt, failedEvt] = shouldHold + ? [METRIC_EVENT_NAMES.TASK_HOLD_SUCCESS, METRIC_EVENT_NAMES.TASK_HOLD_FAILED] + : [METRIC_EVENT_NAMES.TASK_RESUME_SUCCESS, METRIC_EVENT_NAMES.TASK_RESUME_FAILED]; + + this.metricsManager.timeEvent([successEvt, failedEvt]); + + try { + let response: TaskResponse; + if (shouldHold) { + response = await this.contact.hold({ + interactionId: this.data.interactionId, + data: {mediaResourceId: this.data.mediaResourceId}, + }); + this.metricsManager.trackEvent( + successEvt, + { + taskId: this.data.interactionId, + mediaResourceId: this.data.mediaResourceId, + ...MetricsManager.getCommonTrackingFieldForAQMResponse(response), + }, + ['operational', 'behavioral'] + ); + LoggerProxy.log(`Task placed on hold successfully`, { + module: CC_FILE, + method: METHODS.HOLD_RESUME, + trackingId: response.trackingId, + interactionId: this.data.interactionId, + }); + } else { + const mainId = this.data.interaction.mainInteractionId!; + response = await this.contact.unHold({ + interactionId: this.data.interactionId, + data: {mediaResourceId: this.data.mediaResourceId}, + }); + this.metricsManager.trackEvent( + successEvt, + { + taskId: this.data.interactionId, + mainInteractionId: mainId, + mediaResourceId: this.data.mediaResourceId, + ...MetricsManager.getCommonTrackingFieldForAQMResponse(response), + }, + ['operational', 'behavioral'] + ); + LoggerProxy.log(`Task resumed successfully`, { + module: CC_FILE, + method: METHODS.HOLD_RESUME, + trackingId: response.trackingId, + interactionId: this.data.interactionId, + }); + } return response; } catch (error) { - const {error: detailedError} = getErrorDetails(error, 'resume', CC_FILE); + const {error: detailedError} = getErrorDetails(error, 'holdResume', CC_FILE); + this.metricsManager.trackEvent( + failedEvt, + shouldHold + ? { + taskId: this.data.interactionId, + mediaResourceId: this.data.mediaResourceId, + error: error.toString(), + ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed(error.details || {}), + } + : { + taskId: this.data.interactionId, + error: error.toString(), + ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed(error.details || {}), + }, + ['operational', 'behavioral'] + ); throw detailedError; } } @@ -103,12 +368,43 @@ export default class Voice extends Task implements IVoice { */ public async pauseRecording(): Promise { try { + LoggerProxy.info(`Pausing recording`, { + module: CC_FILE, + method: 'pauseRecording', + interactionId: this.data.interactionId, + }); + this.metricsManager.timeEvent([ + METRIC_EVENT_NAMES.TASK_PAUSE_RECORDING_SUCCESS, + METRIC_EVENT_NAMES.TASK_PAUSE_RECORDING_FAILED, + ]); const result = await this.contact.pauseRecording({interactionId: this.data.interactionId}); + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.TASK_PAUSE_RECORDING_SUCCESS, + { + taskId: this.data.interactionId, + ...MetricsManager.getCommonTrackingFieldForAQMResponse(result), + }, + ['operational', 'behavioral', 'business'] + ); + LoggerProxy.log(`Recording paused successfully`, { + module: CC_FILE, + method: 'pauseRecording', + trackingId: result.trackingId, + interactionId: this.data.interactionId, + }); return result; } catch (error) { const {error: detailedError} = getErrorDetails(error, 'pauseRecording', CC_FILE); - + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.TASK_PAUSE_RECORDING_FAILED, + { + taskId: this.data.interactionId, + error: error.toString(), + ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed(error.details || {}), + }, + ['operational', 'behavioral', 'business'] + ); throw detailedError; } } @@ -127,17 +423,48 @@ export default class Voice extends Task implements IVoice { resumeRecordingPayload: ResumeRecordingPayload ): Promise { try { + LoggerProxy.info(`Resuming recording`, { + module: CC_FILE, + method: 'resumeRecording', + interactionId: this.data.interactionId, + }); + this.metricsManager.timeEvent([ + METRIC_EVENT_NAMES.TASK_RESUME_RECORDING_SUCCESS, + METRIC_EVENT_NAMES.TASK_RESUME_RECORDING_FAILED, + ]); resumeRecordingPayload ??= {autoResumed: false}; const result = await this.contact.resumeRecording({ interactionId: this.data.interactionId, data: resumeRecordingPayload, }); + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.TASK_RESUME_RECORDING_SUCCESS, + { + taskId: this.data.interactionId, + ...MetricsManager.getCommonTrackingFieldForAQMResponse(result), + }, + ['operational', 'behavioral', 'business'] + ); + LoggerProxy.log(`Recording resumed successfully`, { + module: CC_FILE, + method: 'resumeRecording', + trackingId: result.trackingId, + interactionId: this.data.interactionId, + }); return result; } catch (error) { const {error: detailedError} = getErrorDetails(error, 'resumeRecording', CC_FILE); - + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.TASK_RESUME_RECORDING_FAILED, + { + taskId: this.data.interactionId, + error: error.toString(), + ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed(error.details || {}), + }, + ['operational', 'behavioral', 'business'] + ); throw detailedError; } } @@ -158,15 +485,201 @@ export default class Voice extends Task implements IVoice { * */ public async consult(consultPayload: ConsultPayload): Promise { try { + LoggerProxy.info(`Starting consult`, { + module: CC_FILE, + method: 'consult', + interactionId: this.data.interactionId, + }); + this.metricsManager.timeEvent([ + METRIC_EVENT_NAMES.TASK_CONSULT_START_SUCCESS, + METRIC_EVENT_NAMES.TASK_CONSULT_START_FAILED, + ]); const result = await this.contact.consult({ interactionId: this.data.interactionId, data: consultPayload, }); + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.TASK_CONSULT_START_SUCCESS, + { + taskId: this.data.interactionId, + destination: consultPayload.to, + destinationType: consultPayload.destinationType, + ...MetricsManager.getCommonTrackingFieldForAQMResponse(result), + }, + ['operational', 'behavioral', 'business'] + ); + LoggerProxy.log(`Consult successfully initiated to ${consultPayload.to}`, { + module: CC_FILE, + method: 'consult', + trackingId: result.trackingId, + interactionId: this.data.interactionId, + }); return result; } catch (error) { const {error: detailedError} = getErrorDetails(error, 'consult', CC_FILE); + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.TASK_CONSULT_START_FAILED, + { + taskId: this.data.interactionId, + destination: consultPayload.to, + destinationType: consultPayload.destinationType, + error: error.toString(), + ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed(error.details || {}), + }, + ['operational', 'behavioral', 'business'] + ); + throw detailedError; + } + } + + /** + * This is used to end the consult session on the task. + * @param consultEndPayload - Payload indicating consult end flags and identifiers + * @returns Promise + * @throws Error + * @example + * ```typescript + * task.endConsult({ + * isConsult: true, + * queueId: 'myQueueId', + * taskId: 'taskId', + * }); + * ``` + */ + public async endConsult(consultEndPayload: ConsultEndPayload): Promise { + try { + LoggerProxy.info(`Ending consult`, { + module: CC_FILE, + method: 'endConsult', + interactionId: this.data.interactionId, + }); + this.metricsManager.timeEvent([ + METRIC_EVENT_NAMES.TASK_CONSULT_END_SUCCESS, + METRIC_EVENT_NAMES.TASK_CONSULT_END_FAILED, + ]); + const result = await this.contact.consultEnd({ + interactionId: this.data.interactionId, + data: consultEndPayload, + }); + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.TASK_CONSULT_END_SUCCESS, + { + taskId: this.data.interactionId, + ...MetricsManager.getCommonTrackingFieldForAQMResponse(result), + }, + ['operational', 'behavioral', 'business'] + ); + LoggerProxy.log(`Consult ended successfully`, { + module: CC_FILE, + method: 'endConsult', + trackingId: result.trackingId, + interactionId: this.data.interactionId, + }); + + return result; + } catch (error) { + const {error: detailedError} = getErrorDetails(error, 'endConsult', CC_FILE); + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.TASK_CONSULT_END_FAILED, + { + taskId: this.data.interactionId, + error: error.toString(), + ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed(error.details || {}), + }, + ['operational', 'behavioral', 'business'] + ); + throw detailedError; + } + } + + /** + * This is used to transfer the task. + * @param payload - Transfer payload + * @returns Promise + * @throws Error + * @example + * ```typescript + * task.transfer({ + * to: 'destinationId', + * destinationType: DESTINATION_TYPE.AGENT, + * consult: true, // Optional, if true will perform a consult transfer else blind transfer + * }); + * ``` + */ + public async transfer(payload: TransferPayLoad): Promise { + try { + LoggerProxy.info(`Transferring task to ${payload.to}`, { + module: CC_FILE, + method: METHODS.TRANSFER_CALL, + interactionId: this.data.interactionId, + }); + this.metricsManager.timeEvent([ + METRIC_EVENT_NAMES.TASK_TRANSFER_SUCCESS, + METRIC_EVENT_NAMES.TASK_TRANSFER_FAILED, + ]); + + // consult transfer path + if (this.data.interaction.state === 'consulting') { + let consultPayload: ConsultTransferPayLoad = { + to: payload.to, + destinationType: payload.destinationType, + }; + + if (payload.destinationType === CONSULT_TRANSFER_DESTINATION_TYPE.QUEUE) { + if (!this.data.destAgentId) { + throw new Error('No agent has accepted this queue consult yet'); + } + consultPayload = { + to: this.data.destAgentId, + destinationType: CONSULT_TRANSFER_DESTINATION_TYPE.AGENT, + }; + } + const result = await this.contact.consultTransfer({ + interactionId: this.data.interactionId, + data: consultPayload, + }); + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.TASK_TRANSFER_SUCCESS, + { + taskId: this.data.interactionId, + destination: consultPayload.to, + destinationType: consultPayload.destinationType, + isConsultTransfer: true, + ...MetricsManager.getCommonTrackingFieldForAQMResponse(result), + }, + ['operational', 'behavioral', 'business'] + ); + LoggerProxy.log(`Consult transfer completed successfully to ${consultPayload.to}`, { + module: CC_FILE, + method: METHODS.TRANSFER_CALL, + trackingId: result.trackingId, + interactionId: this.data.interactionId, + }); + + return result; + } + + // standard blind transfer + return await super.transfer({ + to: payload.to, + destinationType: payload.destinationType, + }); + } catch (err) { + const {error: detailedError} = getErrorDetails(err, METHODS.TRANSFER_CALL, CC_FILE); + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.TASK_TRANSFER_FAILED, + { + taskId: this.data.interactionId, + destination: payload.to, + destinationType: payload.destinationType, + isConsultTransfer: this.data.interaction.state === 'consulting', + error: err.toString(), + ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed(err.details || {}), + }, + ['operational', 'behavioral', 'business'] + ); throw detailedError; } } diff --git a/packages/@webex/plugin-cc/src/services/task/voice/WebRTC.ts b/packages/@webex/plugin-cc/src/services/task/voice/WebRTC.ts index 6929726184e..c35af3d596c 100644 --- a/packages/@webex/plugin-cc/src/services/task/voice/WebRTC.ts +++ b/packages/@webex/plugin-cc/src/services/task/voice/WebRTC.ts @@ -39,7 +39,7 @@ export default class WebRTC extends Voice implements IWebRTC { // TODO: This implementation will change based on the type of task. We need to modify it appropriately, we can even read from task data rather than listening to events switch (this.data.type) { case CC_EVENTS.AGENT_CONTACT_RESERVED: - this.taskUiControls.accept.enable(); + this.taskUiControls.accept.setEnabled(true); break; default: break; diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/task/Task.ts b/packages/@webex/plugin-cc/test/unit/spec/services/task/Task.ts index 454e05b04f2..46136d8fb68 100644 --- a/packages/@webex/plugin-cc/test/unit/spec/services/task/Task.ts +++ b/packages/@webex/plugin-cc/test/unit/spec/services/task/Task.ts @@ -52,17 +52,15 @@ describe('Task (base class)', () => { it('getUIControls returns default controls shape', () => { const controls = task.taskUiControls; - // accept & decline, end & transfer should be visible/enabled - expect(controls.accept.visible).toBe(true); - expect(controls.accept.enabled).toBe(true); - expect(controls.decline.visible).toBe(true); - expect(controls.decline.enabled).toBe(true); - expect(controls.end.visible).toBe(true); - expect(controls.end.enabled).toBe(true); - expect(controls.transfer.visible).toBe(true); - expect(controls.transfer.enabled).toBe(true); - - // all other controls should be hidden/disabled + // all controls should be hidden/disabled + expect(controls.accept.visible).toBe(false); + expect(controls.accept.enabled).toBe(false); + expect(controls.decline.visible).toBe(false); + expect(controls.decline.enabled).toBe(false); + expect(controls.end.visible).toBe(false); + expect(controls.end.enabled).toBe(false); + expect(controls.transfer.visible).toBe(false); + expect(controls.transfer.enabled).toBe(false); expect(controls.hold.visible).toBe(false); expect(controls.hold.enabled).toBe(false); expect(controls.mute.visible).toBe(false); diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/task/TaskManager.ts b/packages/@webex/plugin-cc/test/unit/spec/services/task/TaskManager.ts index feee070c266..b10d571da54 100644 --- a/packages/@webex/plugin-cc/test/unit/spec/services/task/TaskManager.ts +++ b/packages/@webex/plugin-cc/test/unit/spec/services/task/TaskManager.ts @@ -12,6 +12,7 @@ import WebCallingService from '../../../../../src/services/WebCallingService'; import config from '../../../../../src/config'; import {CC_TASK_EVENTS} from '../../../../../src/services/config/types'; import TaskFactory from '../../../../../src/services/task/TaskFactory'; +import { wrap } from 'module'; describe('TaskManager', () => { let mockCall; @@ -495,7 +496,7 @@ describe('TaskManager', () => { webSocketManagerMock.emit('message', JSON.stringify(wrapupPayload)); - expect(updateTaskDataSpy).toHaveBeenCalledWith(wrapupPayload.data); + expect(updateTaskDataSpy).toHaveBeenCalledWith({...wrapupPayload.data, wrapUpRequired: true}); }); it('should emit TASK_HOLD event on AGENT_CONTACT_HELD event', () => { diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/task/digital/Digital.ts b/packages/@webex/plugin-cc/test/unit/spec/services/task/digital/Digital.ts index 7e1f6897fbd..93170b0c1ce 100644 --- a/packages/@webex/plugin-cc/test/unit/spec/services/task/digital/Digital.ts +++ b/packages/@webex/plugin-cc/test/unit/spec/services/task/digital/Digital.ts @@ -25,7 +25,7 @@ describe('Digital Task', () => { it('default UI controls remain unchanged', () => { const task = new Digital(dummyContact as any, dummyData); - expect(task.taskUiControls.accept.visible).toBe(true); - expect(task.taskUiControls.decline.visible).toBe(true); + expect(task.taskUiControls.accept.visible).toBe(false); + expect(task.taskUiControls.decline.visible).toBe(false); }); }); diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/task/voice/Voice.ts b/packages/@webex/plugin-cc/test/unit/spec/services/task/voice/Voice.ts index 4ac62767ccc..2e7b6165fe5 100644 --- a/packages/@webex/plugin-cc/test/unit/spec/services/task/voice/Voice.ts +++ b/packages/@webex/plugin-cc/test/unit/spec/services/task/voice/Voice.ts @@ -1,5 +1,20 @@ import Voice from '../../../../../../src/services/task/voice/Voice'; import { TaskData } from '../../../../../../src/services/task/types'; +import { CC_EVENTS } from '../../../../../../src/services/config/types'; +import { CONSULT_TRANSFER_DESTINATION_TYPE } from '../../../../../../src/services/task/types'; +import e from 'express'; + +jest.mock('../../../../../../src/services/core/WebexRequest', () => ({ + __esModule: true, + default: { + getInstance: () => ({ uploadLogs: jest.fn() }), + }, +})); + +jest.mock('../../../../../../src/services/core/Utils', () => ({ + __esModule: true, + getErrorDetails: (err: any) => ({ error: err }), +})); describe('Voice Task', () => { const dummyContact = { @@ -15,7 +30,7 @@ describe('Voice Task', () => { mediaResourceId: 'media1', interaction: { mainInteractionId: 'main1', - media: { main1: { mediaResourceId: 'media1' } }, + media: { 'media1': { mediaResourceId: 'media1', isHold: false }}, }, } as unknown as TaskData; @@ -29,40 +44,29 @@ describe('Voice Task', () => { expect(voice.taskUiControls.endConsult.visible).toBe(false); }); - it('does not override end and endConsult when enabled', () => { - const voice = new Voice(dummyContact, baseData, { - isEndCallEnabled: true, - isEndConsultEnabled: true, - }); - voice.updateTaskData(baseData); - expect(voice.taskUiControls.end.visible).toBe(true); - expect(voice.taskUiControls.endConsult.visible).toBe(false); // By default it is not visible - }); - - it('hold() calls contact.hold with correct params', async () => { - const voice = new Voice(dummyContact, baseData, { - isEndCallEnabled: true, - isEndConsultEnabled: true, - }); - const res = await voice.hold(); + it('calls contact.hold when media is not held', async () => { + const voice = new Voice(dummyContact, baseData as any, {}); + await voice.holdResume(); expect(dummyContact.hold).toHaveBeenCalledWith({ interactionId: 'int1', data: { mediaResourceId: 'media1' }, }); - expect(res).toBe('held'); }); - it('resume() calls contact.unHold with correct mediaResourceId', async () => { - const voice = new Voice(dummyContact, baseData, { - isEndCallEnabled: true, - isEndConsultEnabled: true, - }); - const res = await voice.resume(); + it('calls contact.unHold when media is held', async () => { + const heldData = { + ...baseData, + interaction: { + ...baseData.interaction, + media: { 'media1': { mediaResourceId: 'media1', isHold: true }}, + }, + } as any; + const voice = new Voice(dummyContact, heldData, {}); + await voice.holdResume(); expect(dummyContact.unHold).toHaveBeenCalledWith({ interactionId: 'int1', data: { mediaResourceId: 'media1' }, }); - expect(res).toBe('resumed'); }); it('pauseRecording() calls contact.pauseRecording', async () => { @@ -72,7 +76,6 @@ describe('Voice Task', () => { }); const res = await voice.pauseRecording(); expect(dummyContact.pauseRecording).toHaveBeenCalledWith({ interactionId: 'int1' }); - expect(res).toBe('paused'); }); it('resumeRecording() with no payload defaults to autoResumed false', async () => { @@ -85,7 +88,6 @@ describe('Voice Task', () => { interactionId: 'int1', data: { autoResumed: false }, }); - expect(res).toBe('resumedRecording'); }); it('consult() calls contact.consult with payload', async () => { @@ -99,6 +101,322 @@ describe('Voice Task', () => { interactionId: 'int1', data: payload, }); - expect(res).toBe('consulted'); + }); + + describe('transfer()', () => { + it('calls contact.consultTransfer for consult transfer to agent', async () => { + const consultTransferMock = jest.fn().mockResolvedValue('consultedA'); + const dataWithState = { + ...baseData, + interaction: { ...baseData.interaction, state: 'consulting' }, + }; + const voice = new Voice( + { ...dummyContact, consultTransfer: consultTransferMock }, + dataWithState as any, + { isEndCallEnabled: true, isEndConsultEnabled: true } + ); + + const result = await voice.transfer({ + to: 'destB', + destinationType: 'agent', + }); + + expect(consultTransferMock).toHaveBeenCalledWith({ + interactionId: 'int1', + data: { to: 'destB', destinationType: 'agent' }, + }); + }); + + it('throws if consult transfer to QUEUE but no destAgentId set', async () => { + const dataWithState = { + ...baseData, + interaction: { ...baseData.interaction, state: 'consulting' }, + }; + const voice = new Voice(dummyContact, dataWithState as any, { + isEndCallEnabled: true, + isEndConsultEnabled: true, + }); + + await expect( + voice.transfer({ + to: 'queue1', + destinationType: CONSULT_TRANSFER_DESTINATION_TYPE.QUEUE, + }) + ).rejects.toThrow('No agent has accepted this queue consult yet'); + }); + + it('uses data.destAgentId for queue consult transfer', async () => { + const consultTransferMock = jest.fn().mockResolvedValue('consultedQ'); + const dataWithDest = { + ...baseData, + destAgentId: 'agentD', + interaction: { ...baseData.interaction, state: 'consulting' }, + }; + const voice = new Voice( + { ...dummyContact, consultTransfer: consultTransferMock }, + dataWithDest as any, + { isEndCallEnabled: true, isEndConsultEnabled: true } + ); + + const result = await voice.transfer({ + to: 'queueX', + destinationType: CONSULT_TRANSFER_DESTINATION_TYPE.QUEUE, + }); + + expect(consultTransferMock).toHaveBeenCalledWith({ + interactionId: 'int1', + data: { + to: 'agentD', + destinationType: CONSULT_TRANSFER_DESTINATION_TYPE.AGENT, + }, + }); + }); + }); + + describe('endConsult()', () => { + it('calls contact.consultEnd with correct payload', async () => { + const consultEndMock = jest.fn().mockResolvedValue('endedC'); + const voice = new Voice( + { ...dummyContact, consultEnd: consultEndMock }, + baseData, + { isEndCallEnabled: true, isEndConsultEnabled: true } + ); + const payload = { isConsult: true, queueId: 'q1', taskId: 't1' }; + const result = await voice.endConsult(payload); + + expect(consultEndMock).toHaveBeenCalledWith({ + interactionId: 'int1', + data: payload, + }); + expect(result).toBe('endedC'); + }); + }); + + describe('UI controls for AGENT_CONTACT_ASSIGNED', () => { + it('shows main controls and hides accept/decline on AGENT_CONTACT_ASSIGNED', () => { + const data: any = { ...baseData, type: CC_EVENTS.AGENT_CONTACT_ASSIGNED }; + const voice = new Voice(dummyContact, data, { + isEndCallEnabled: true, + isEndConsultEnabled: false, + }); + + voice.updateTaskData(data); + + expect(voice.taskUiControls.accept.visible).toBe(false); + expect(voice.taskUiControls.decline.visible).toBe(false); + expect(voice.taskUiControls.hold.visible).toBe(true); + expect(voice.taskUiControls.transfer.visible).toBe(true); + expect(voice.taskUiControls.consult.visible).toBe(true); + expect(voice.taskUiControls.recording.visible).toBe(true); + expect(voice.taskUiControls.end.visible).toBe(true); + expect(voice.taskUiControls.endConsult.visible).toBe(false); + }); + }); + + describe('UI controls for various CC_EVENTS', () => { + const make = (evt: any, opts: any = {}) => { + const data: any = { + ...baseData, + type: evt, + interaction: { ...baseData.interaction, state: opts.state || 'active' }, + isConsulted: opts.isConsulted, + destAgentId: opts.destAgentId, + }; + const voice = new Voice(dummyContact, data, { + isEndCallEnabled: opts.endCall ?? true, + isEndConsultEnabled: opts.endConsult ?? true, + }); + voice.updateTaskData(data); + return voice.taskUiControls; + }; + + it('AGENT_CONTACT_UNASSIGNED hides consultTransfer/recording/end and shows wrapup', () => { + const ctrl = make(CC_EVENTS.AGENT_CONTACT_UNASSIGNED); + expect(ctrl.consultTransfer.visible).toBe(false); + expect(ctrl.recording.visible).toBe(false); + expect(ctrl.end.visible).toBe(false); + expect(ctrl.wrapup.visible).toBe(true); + expect(ctrl.wrapup.enabled).toBe(true); + }); + + it('CONTACT_ENDED with state new hides all and no wrapup', () => { + const ctrl = make(CC_EVENTS.CONTACT_ENDED, { state: 'new' }); + ['hold', 'transfer', 'consult', 'consultTransfer', 'recording', 'end', 'endConsult', 'wrapup'].forEach( + (k) => expect((ctrl as any)[k].visible).toBe(false) + ); + }); + + it('CONTACT_ENDED with state active hides all except wrapup', () => { + const ctrl = make(CC_EVENTS.CONTACT_ENDED, { state: 'ended' }); + ['hold', 'transfer', 'consult', 'consultTransfer', 'recording', 'end', 'endConsult'].forEach( + (k) => expect((ctrl as any)[k].visible).toBe(false) + ); + expect(ctrl.wrapup.visible).toBe(true); + expect(ctrl.wrapup.enabled).toBe(true); + }); + + it('AGENT_CONTACT_HELD shows main controls and end disabled', () => { + const ctrl = make(CC_EVENTS.AGENT_CONTACT_HELD); + ['hold', 'transfer', 'consult', 'recording'].forEach((k) => + expect((ctrl as any)[k].visible).toBe(true) + ); + expect(ctrl.end.visible).toBe(true); + expect(ctrl.end.enabled).toBe(false); + }); + + it('AGENT_CONTACT_UNHELD shows main controls and end enabled', () => { + const ctrl = make(CC_EVENTS.AGENT_CONTACT_UNHELD); + ['hold', 'transfer', 'consult', 'recording'].forEach((k) => + expect((ctrl as any)[k].visible).toBe(true) + ); + expect(ctrl.end.visible).toBe(true); + expect(ctrl.end.enabled).toBe(true); + }); + + it('AGENT_VTEAM_TRANSFERRED hides all except wrapup', () => { + const ctrl = make(CC_EVENTS.AGENT_VTEAM_TRANSFERRED); + ['hold', 'transfer', 'consult', 'consultTransfer', 'recording', 'end'].forEach((k) => + expect((ctrl as any)[k].visible).toBe(false) + ); + expect(ctrl.wrapup.visible).toBe(true); + expect(ctrl.wrapup.enabled).toBe(true); + }); + + it('AGENT_CTQ_CANCEL_FAILED shows main and end enabled', () => { + const ctrl = make(CC_EVENTS.AGENT_CTQ_CANCEL_FAILED); + ['hold', 'transfer', 'consult', 'recording'].forEach((k) => + expect((ctrl as any)[k].visible).toBe(true) + ); + expect(ctrl.end.visible).toBe(true); + expect(ctrl.end.enabled).toBe(true); + }); + + it('AGENT_CONSULT_CREATED when not consulted toggles correctly', () => { + const ctrl = make(CC_EVENTS.AGENT_CONSULT_CREATED, { isConsulted: false }); + ['hold', 'consult'].forEach((k) => + expect((ctrl as any)[k].visible).toBe(false) + ); + expect(ctrl.end.visible).toBe(true); + expect(ctrl.end.enabled).toBe(false); + expect(ctrl.transfer.visible).toBe(true); + expect(ctrl.transfer.enabled).toBe(false); + expect(ctrl.consultTransfer.visible).toBe(true); + expect(ctrl.consultTransfer.enabled).toBe(false); + expect(ctrl.recording.visible).toBe(true); + expect(ctrl.recording.enabled).toBe(false); + expect(ctrl.endConsult.visible).toBe(true); + expect(ctrl.endConsult.enabled).toBe(true); + }); + + it('AGENT_OFFER_CONSULT respects endConsult flag', () => { + const ctrl1 = make(CC_EVENTS.AGENT_OFFER_CONSULT, { endConsult: true }); + expect(ctrl1.endConsult.visible).toBe(true); + expect(ctrl1.endConsult.enabled).toBe(true); + const ctrl2 = make(CC_EVENTS.AGENT_OFFER_CONSULT, { endConsult: false }); + expect(ctrl2.endConsult.visible).toBe(false); + }); + + it('AGENT_CONSULTING when starting hides main and shows consultTransfer etc.', () => { + const ctrl = make(CC_EVENTS.AGENT_CONSULTING, { isConsulted: false }); + ['hold', 'consult'].forEach((k) => + expect((ctrl as any)[k].visible).toBe(false) + ); + expect(ctrl.transfer.visible).toBe(true); + expect(ctrl.transfer.enabled).toBe(false); + expect(ctrl.consultTransfer.visible).toBe(true); + expect(ctrl.consultTransfer.enabled).toBe(true); + expect(ctrl.recording.visible).toBe(true); + expect(ctrl.recording.enabled).toBe(false); + expect(ctrl.endConsult.visible).toBe(true); + expect(ctrl.endConsult.enabled).toBe(true); + expect(ctrl.end.visible).toBe(true); + expect(ctrl.end.enabled).toBe(false); + }); + + it('AGENT_CONSULTING when consulted only shows endConsult if allowed', () => { + const ctrl = make(CC_EVENTS.AGENT_CONSULTING, { isConsulted: true, endConsult: true }); + expect(ctrl.endConsult.visible).toBe(true); + expect(ctrl.endConsult.enabled).toBe(true); + }); + + it('AGENT_CONSULT_FAILED resets to main and hides transfer/wrapup', () => { + const ctrl = make(CC_EVENTS.AGENT_CONSULT_FAILED, { isConsulted: false }); + ['hold', 'transfer', 'consult', 'recording'].forEach((k) => + expect((ctrl as any)[k].visible).toBe(true) + ); + expect(ctrl.end.visible).toBe(true); + expect(ctrl.consultTransfer.visible).toBe(false); + expect(ctrl.wrapup.visible).toBe(false); + }); + }); + + describe('UI controls for AGENT_CONTACT', () => { + const makeContact = (opts: { + state: string; + isConsulted?: boolean; + isTerminated?: boolean; + endCall?: boolean; + endConsult?: boolean; + }) => { + const data: any = { + ...baseData, + type: CC_EVENTS.AGENT_CONTACT, + interaction: { + ...baseData.interaction, + state: opts.state, + isTerminated: opts.isTerminated || false, + }, + isConsulted: opts.isConsulted || false, + }; + const voice = new Voice(dummyContact, data, { + isEndCallEnabled: opts.endCall ?? true, + isEndConsultEnabled: opts.endConsult ?? true, + }); + voice.updateTaskData(data); + return voice.taskUiControls; + }; + + it('hides all and shows wrapup when terminated', () => { + const ctrl = makeContact({ state: 'connected', isTerminated: true }); + ['hold', 'transfer', 'consult', 'consultTransfer', 'recording', 'end'].forEach((k) => + expect((ctrl as any)[k].visible).toBe(false) + ); + expect(ctrl.wrapup.visible).toBe(true); + expect(ctrl.wrapup.enabled).toBe(true); + }); + + it('shows main and end enabled when connected (not consulted)', () => { + const ctrl = makeContact({ state: 'connected', isConsulted: false }); + ['hold', 'transfer', 'consult', 'recording'].forEach((k) => + expect((ctrl as any)[k].visible).toBe(true) + ); + expect(ctrl.end.visible).toBe(true); + expect(ctrl.end.enabled).toBe(true); + }); + + it('consulting (not consulted) hides main, shows consultTransfer/endConsult, end disabled', () => { + const ctrl = makeContact({ state: 'consulting', isConsulted: false, endCall: true }); + ['hold', 'transfer', 'consult'].forEach((k) => + expect((ctrl as any)[k].visible).toBe(false) + ); + expect(ctrl.consultTransfer.visible).toBe(true); + expect(ctrl.consultTransfer.enabled).toBe(true); + expect(ctrl.endConsult.visible).toBe(true); + expect(ctrl.endConsult.enabled).toBe(true); + expect(ctrl.end.visible).toBe(true); + expect(ctrl.end.enabled).toBe(false); + }); + + it('consulting (consulted) hides main and shows only endConsult when allowed', () => { + const ctrl = makeContact({ state: 'consulting', isConsulted: true, endConsult: true }); + ['hold', 'transfer', 'consult', 'consultTransfer'].forEach((k) => + expect((ctrl as any)[k].visible).toBe(false) + ); + expect(ctrl.recording.visible).toBe(true); + expect(ctrl.recording.enabled).toBe(false); + expect(ctrl.endConsult.visible).toBe(true); + expect(ctrl.endConsult.enabled).toBe(true); + expect(ctrl.end.visible).toBe(false); + }); }); }); diff --git a/packages/@webex/plugin-cc/typedoc.md b/packages/@webex/plugin-cc/typedoc.md index a0c9e365a56..527901a395d 100644 --- a/packages/@webex/plugin-cc/typedoc.md +++ b/packages/@webex/plugin-cc/typedoc.md @@ -77,7 +77,7 @@ For a full list of configuration options, see the , data: TaskData) { + super(contact, data); + this.updateTaskUiControls({accept: [true, true]}); + } + /** * This is used for incoming digital task accept by agent. * @@ -16,12 +26,106 @@ export default class Digital extends Task implements IDigital { */ public async accept(): Promise { try { - return this.contact.accept({interactionId: this.data.interactionId}); + LoggerProxy.info(`Accepting task`, { + module: CC_FILE, + method: METHODS.ACCEPT, + interactionId: this.data.interactionId, + }); + + this.metricsManager.timeEvent([ + METRIC_EVENT_NAMES.TASK_ACCEPT_SUCCESS, + METRIC_EVENT_NAMES.TASK_ACCEPT_FAILED, + ]); + + const response = await this.contact.accept({interactionId: this.data.interactionId}); + + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.TASK_ACCEPT_SUCCESS, + { + taskId: this.data.interactionId, + ...MetricsManager.getCommonTrackingFieldForAQMResponse(response), + }, + ['operational', 'behavioral', 'business'] + ); + LoggerProxy.log(`Task accepted successfully`, { + module: CC_FILE, + method: METHODS.ACCEPT, + trackingId: response.trackingId, + interactionId: this.data.interactionId, + }); + + return response; } catch (error) { - const {error: detailedError} = getErrorDetails(error, 'accept', CC_FILE); + const {error: detailedError} = getErrorDetails(error, METHODS.ACCEPT, CC_FILE); + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.TASK_ACCEPT_FAILED, + { + taskId: this.data.interactionId, + error: error.toString(), + ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed(error.details || {}), + }, + ['operational', 'behavioral', 'business'] + ); throw detailedError; } } - protected setUIControls(): void {} + protected setUIControls(): void { + const eventType = this.data.type; + + switch (eventType) { + case CC_EVENTS.AGENT_CONTACT_ASSIGNED: + // once accepted: enable transfer + end + this.updateTaskUiControls({ + accept: [false, false], + transfer: [true, true], + end: [true, true], + }); + break; + + case CC_EVENTS.AGENT_VTEAM_TRANSFERRED: + case CC_EVENTS.AGENT_BLIND_TRANSFERRED: + case CC_EVENTS.AGENT_WRAPUP: + // after transfer or end: enable wrapup + this.updateTaskUiControls({ + transfer: [false, false], + end: [false, false], + wrapup: [true, true], + }); + break; + + case CC_EVENTS.AGENT_CONTACT: + if (this.data.interaction.isTerminated) { + this.updateTaskUiControls({ + transfer: [false, false], + end: [false, false], + wrapup: [true, true], + }); + } else if (this.data.interaction.state === 'connected') { + this.updateTaskUiControls({ + accept: [false, false], + transfer: [true, true], + end: [true, true], + }); + } else if (this.data.interaction.state === 'new') { + this.updateTaskUiControls({ + accept: [true, true], + transfer: [false, false], + end: [false, false], + }); + } + break; + + case CC_EVENTS.AGENT_CONTACT_OFFER_RONA: + this.updateTaskUiControls({ + accept: [false, false], + transfer: [false, false], + end: [false, false], + }); + break; + + default: + break; + } + } } diff --git a/packages/@webex/plugin-cc/src/services/task/voice/WebRTC.ts b/packages/@webex/plugin-cc/src/services/task/voice/WebRTC.ts index 6d4485fb47a..24996275e55 100644 --- a/packages/@webex/plugin-cc/src/services/task/voice/WebRTC.ts +++ b/packages/@webex/plugin-cc/src/services/task/voice/WebRTC.ts @@ -23,6 +23,7 @@ export default class WebRTC extends Voice implements IWebRTC { super(contact, data, callOptions); this.updateTaskUiControls({accept: [true, true], decline: [true, true]}); this.webCallingService = webCallingService; + this.registerWebCallListeners(); } private registerWebCallListeners() { diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/task/TaskFactory.ts b/packages/@webex/plugin-cc/test/unit/spec/services/task/TaskFactory.ts index 61e1b0f8fc6..c2e054af6b9 100644 --- a/packages/@webex/plugin-cc/test/unit/spec/services/task/TaskFactory.ts +++ b/packages/@webex/plugin-cc/test/unit/spec/services/task/TaskFactory.ts @@ -4,6 +4,7 @@ import {MEDIA_CHANNEL, TaskData} from '../../../../../src/services/task/types'; import {LoginOption} from '../../../../../src/types'; import WebCallingService from '../../../../../src/services/WebCallingService'; import {ConfigFlags} from '../../../../../src/types'; +import register from '@babel/register'; describe('TaskFactory', () => { const dummyContact = {} as any; @@ -13,7 +14,11 @@ describe('TaskFactory', () => { }; const makeSvc = (loginOption: LoginOption) => - ({loginOption} as unknown) as WebCallingService; + ({ + loginOption, + on: jest.fn(), + off: jest.fn?.(), + } as unknown) as WebCallingService; const configFlags: ConfigFlags = { isEndCallEnabled: true, diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/task/digital/Digital.ts b/packages/@webex/plugin-cc/test/unit/spec/services/task/digital/Digital.ts index 93170b0c1ce..596e2e9f4b2 100644 --- a/packages/@webex/plugin-cc/test/unit/spec/services/task/digital/Digital.ts +++ b/packages/@webex/plugin-cc/test/unit/spec/services/task/digital/Digital.ts @@ -1,31 +1,124 @@ import Digital from '../../../../../../src/services/task/digital/Digital'; -import { TaskResponse } from '../../../../../../src/services/task/types'; +import { TaskData, TaskResponse } from '../../../../../../src/services/task/types'; +import { CC_EVENTS } from '../../../../../../src/services/config/types'; + +jest.mock('../../../../../../src/services/core/WebexRequest', () => ({ + __esModule: true, + default: { + getInstance: () => ({ uploadLogs: jest.fn() }), + }, +})); describe('Digital Task', () => { - const dummyData = { interactionId: 'dig1' } as any; + const dummyData = { interactionId: 'dig1' } as TaskData; let dummyContact: { accept: jest.Mock> }; beforeEach(() => { - dummyContact = { accept: jest.fn().mockResolvedValue({ status: 'ok' } as any) }; + dummyContact = { + accept: jest.fn().mockResolvedValue({ status: 'ok' }), + }; }); it('accept() calls contact.accept with interactionId', async () => { - const task = new Digital(dummyContact as any, dummyData); + const task = new Digital(dummyContact, dummyData); const res = await task.accept(); expect(dummyContact.accept).toHaveBeenCalledWith({ interactionId: 'dig1' }); expect(res).toEqual({ status: 'ok' }); }); - it('accept() throws when contact.accept rejects', async () => { - const error = new Error('fail'); - dummyContact.accept.mockRejectedValue(error); - const task = new Digital(dummyContact as any, dummyData); - await expect(task.accept()).rejects.toThrow('fail'); + it('accept() throws an error when contact.accept rejects', async () => { + const error = new Error('Error while performing accept'); + (dummyContact.accept as jest.Mock).mockRejectedValue(error); + const task = new Digital(dummyContact, dummyData); + await expect(task.accept()).rejects.toThrow('Error while performing accept'); + }); + + it('constructor enables accept by default', () => { + const task = new Digital(dummyContact, dummyData); + // after constructor, accept visible & enabled + expect(task.taskUiControls.accept.visible).toBe(true); + expect(task.taskUiControls.accept.enabled).toBe(true); }); - it('default UI controls remain unchanged', () => { - const task = new Digital(dummyContact as any, dummyData); - expect(task.taskUiControls.accept.visible).toBe(false); - expect(task.taskUiControls.decline.visible).toBe(false); + describe('setUIControls for AGENT_CONTACT events', () => { + function make(data: Partial & { type: string }) { + const full = { + interactionId: 'dig1', + interaction: { isTerminated: false, state: 'new' }, + ...data, + } as TaskData; + const task = new Digital(dummyContact, full); + task.updateTaskData(full); + return task.taskUiControls; + } + + it('new state shows accept only', () => { + const ctrl = make({ type: CC_EVENTS.AGENT_CONTACT, interaction: { isTerminated: false, state: 'new' } } as Partial & { type: string }); + expect(ctrl.accept.visible).toBe(true); + expect(ctrl.transfer.visible).toBe(false); + expect(ctrl.end.visible).toBe(false); + expect(ctrl.wrapup.visible).toBe(false); + }); + + it('connected state shows transfer and end', () => { + const ctrl = make({ type: CC_EVENTS.AGENT_CONTACT, interaction: { isTerminated: false, state: 'connected' } } as Partial & { type: string }); + expect(ctrl.accept.visible).toBe(false); + expect(ctrl.transfer.visible).toBe(true); + expect(ctrl.end.visible).toBe(true); + expect(ctrl.wrapup.visible).toBe(false); + }); + + it('terminated shows wrapup only', () => { + const ctrl = make({ type: CC_EVENTS.AGENT_CONTACT, interaction: { isTerminated: true, state: 'connected' } } as Partial & { type: string }); + expect(ctrl.transfer.visible).toBe(false); + expect(ctrl.end.visible).toBe(false); + expect(ctrl.wrapup.visible).toBe(true); + expect(ctrl.wrapup.enabled).toBe(true); + }); + }); + + describe('other CC_EVENTS paths', () => { + function ctrlFor(type: string) { + const data = { + ...dummyData, + type, + interaction: { isTerminated: false, state: 'new' }, + } as TaskData; + const task = new Digital(dummyContact, data); + task.updateTaskData(data); + return task.taskUiControls; + } + + it('AGENT_OFFER_CONTACT enables accept', () => { + const ctrl = ctrlFor(CC_EVENTS.AGENT_OFFER_CONTACT); + expect(ctrl.accept.visible).toBe(true); + }); + + it('AGENT_CONTACT_ASSIGNED shows transfer and end, hides accept', () => { + const ctrl = ctrlFor(CC_EVENTS.AGENT_CONTACT_ASSIGNED); + expect(ctrl.accept.visible).toBe(false); + expect(ctrl.transfer.visible).toBe(true); + expect(ctrl.end.visible).toBe(true); + }); + + it('AGENT_VTEAM_TRANSFERRED enables wrapup only', () => { + const ctrl = ctrlFor(CC_EVENTS.AGENT_VTEAM_TRANSFERRED); + expect(ctrl.transfer.visible).toBe(false); + expect(ctrl.end.visible).toBe(false); + expect(ctrl.wrapup.visible).toBe(true); + }); + + it('AGENT_WRAPUP enables wrapup only', () => { + const ctrl = ctrlFor(CC_EVENTS.AGENT_WRAPUP); + expect(ctrl.wrapup.visible).toBe(true); + }); + + it('AGENT_CONTACT_OFFER_RONA disables accept, transfer, end, and wrapup', () => { + const ctrl = ctrlFor(CC_EVENTS.AGENT_CONTACT_OFFER_RONA); + expect(ctrl.accept.visible).toBe(false); + expect(ctrl.transfer.visible).toBe(false); + expect(ctrl.end.visible).toBe(false); + expect(ctrl.wrapup.visible).toBe(false); + }); }); }); From 7437bab2b2dc8b8ce85ae5ba2cf6e4b74b2dec4c Mon Sep 17 00:00:00 2001 From: arungane Date: Wed, 29 Oct 2025 12:25:53 -0400 Subject: [PATCH 7/8] fix(contact center): change the folder name to contact center --- package.json | 2 +- packages/@webex/contact-center/Archive.zip | Bin 0 -> 518286 bytes .../{plugin-cc => contact-center}/README.md | 8 +- .../__mocks__/workerMock.js | 0 .../babel.config.js | 0 .../jest.config.js | 0 .../package.json | 4 +- .../{plugin-cc => contact-center}/src/cc.ts | 0 .../src/config.ts | 0 .../src/constants.ts | 0 .../src/index.ts | 0 .../src/logger-proxy.ts | 0 .../src/metrics/MetricsManager.ts | 0 .../src/metrics/behavioral-events.ts | 0 .../src/metrics/constants.ts | 0 .../src/services/WebCallingService.ts | 0 .../src/services/agent/index.ts | 0 .../src/services/agent/types.ts | 0 .../src/services/config/Util.ts | 0 .../src/services/config/constants.ts | 0 .../src/services/config/index.ts | 0 .../src/services/config/types.ts | 0 .../src/services/constants.ts | 0 .../src/services/core/Err.ts | 0 .../src/services/core/GlobalTypes.ts | 0 .../src/services/core/Utils.ts | 0 .../src/services/core/WebexRequest.ts | 0 .../src/services/core/aqm-reqs.ts | 0 .../src/services/core/constants.ts | 0 .../src/services/core/types.ts | 0 .../core/websocket/WebSocketManager.ts | 0 .../core/websocket/connection-service.ts | 0 .../core/websocket/keepalive.worker.js | 0 .../src/services/core/websocket/types.ts | 0 .../src/services/index.ts | 0 .../src/services/task/Task.ts | 0 .../src/services/task/TaskFactory.ts | 0 .../src/services/task/TaskManager.ts | 0 .../src/services/task/constants.ts | 2 +- .../src/services/task/contact.ts | 0 .../src/services/task/dialer.ts | 0 .../src/services/task/digital/Digital.ts | 0 .../src/services/task/index.ts | 0 .../src/services/task/types.ts | 0 .../src/services/task/voice/Voice.ts | 0 .../src/services/task/voice/WebRTC.ts | 0 .../src/types.ts | 0 .../src/webex-config.ts | 0 .../src/webex.js | 4 +- .../test/unit/spec/cc.ts | 0 .../test/unit/spec/metrics/MetricsManager.ts | 0 .../unit/spec/metrics/behavioral-events.ts | 0 .../unit/spec/services/WebCallingService.ts | 0 .../test/unit/spec/services/agent/index.ts | 0 .../test/unit/spec/services/config/index.ts | 0 .../test/unit/spec/services/core/Utils.ts | 0 .../unit/spec/services/core/WebexRequest.ts | 0 .../test/unit/spec/services/core/aqm-reqs.ts | 0 .../core/websocket/WebSocketManager.ts | 0 .../core/websocket/connection-service.ts | 0 .../test/unit/spec/services/task/Task.ts | 0 .../unit/spec/services/task/TaskFactory.ts | 0 .../unit/spec/services/task/TaskManager.ts | 0 .../test/unit/spec/services/task/contact.ts | 0 .../test/unit/spec/services/task/dialer.ts | 0 .../spec/services/task/digital/Digital.ts | 0 .../test/unit/spec/services/task/index.ts | 0 .../unit/spec/services/task/voice/Voice.ts | 0 .../unit/spec/services/task/voice/WebRTC.ts | 0 .../tsconfig.json | 0 .../typedoc.json | 2 +- .../{plugin-cc => contact-center}/typedoc.md | 8 +- packages/webex/package.json | 2 +- packages/webex/src/webex.js | 2 +- webpack.config.js | 2 +- yarn.lock | 80 +++++++++--------- 76 files changed, 58 insertions(+), 58 deletions(-) create mode 100644 packages/@webex/contact-center/Archive.zip rename packages/@webex/{plugin-cc => contact-center}/README.md (88%) rename packages/@webex/{plugin-cc => contact-center}/__mocks__/workerMock.js (100%) rename packages/@webex/{plugin-cc => contact-center}/babel.config.js (100%) rename packages/@webex/{plugin-cc => contact-center}/jest.config.js (100%) rename packages/@webex/{plugin-cc => contact-center}/package.json (97%) rename packages/@webex/{plugin-cc => contact-center}/src/cc.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/config.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/constants.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/index.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/logger-proxy.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/metrics/MetricsManager.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/metrics/behavioral-events.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/metrics/constants.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/services/WebCallingService.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/services/agent/index.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/services/agent/types.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/services/config/Util.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/services/config/constants.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/services/config/index.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/services/config/types.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/services/constants.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/services/core/Err.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/services/core/GlobalTypes.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/services/core/Utils.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/services/core/WebexRequest.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/services/core/aqm-reqs.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/services/core/constants.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/services/core/types.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/services/core/websocket/WebSocketManager.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/services/core/websocket/connection-service.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/services/core/websocket/keepalive.worker.js (100%) rename packages/@webex/{plugin-cc => contact-center}/src/services/core/websocket/types.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/services/index.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/services/task/Task.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/services/task/TaskFactory.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/services/task/TaskManager.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/services/task/constants.ts (96%) rename packages/@webex/{plugin-cc => contact-center}/src/services/task/contact.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/services/task/dialer.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/services/task/digital/Digital.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/services/task/index.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/services/task/types.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/services/task/voice/Voice.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/services/task/voice/WebRTC.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/types.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/webex-config.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/src/webex.js (97%) rename packages/@webex/{plugin-cc => contact-center}/test/unit/spec/cc.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/test/unit/spec/metrics/MetricsManager.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/test/unit/spec/metrics/behavioral-events.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/test/unit/spec/services/WebCallingService.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/test/unit/spec/services/agent/index.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/test/unit/spec/services/config/index.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/test/unit/spec/services/core/Utils.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/test/unit/spec/services/core/WebexRequest.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/test/unit/spec/services/core/aqm-reqs.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/test/unit/spec/services/core/websocket/WebSocketManager.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/test/unit/spec/services/core/websocket/connection-service.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/test/unit/spec/services/task/Task.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/test/unit/spec/services/task/TaskFactory.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/test/unit/spec/services/task/TaskManager.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/test/unit/spec/services/task/contact.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/test/unit/spec/services/task/dialer.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/test/unit/spec/services/task/digital/Digital.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/test/unit/spec/services/task/index.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/test/unit/spec/services/task/voice/Voice.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/test/unit/spec/services/task/voice/WebRTC.ts (100%) rename packages/@webex/{plugin-cc => contact-center}/tsconfig.json (100%) rename packages/@webex/{plugin-cc => contact-center}/typedoc.json (94%) rename packages/@webex/{plugin-cc => contact-center}/typedoc.md (90%) diff --git a/package.json b/package.json index 91567fce78f..d4a9e008729 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "build:dev": "NODE_ENV=development node ./tooling/index.js build", "prebuild:modules": "yarn && yarn @tools build:src && yarn @legacy-tools build:src && yarn workspace @webex/webex-core build:src && yarn @all build:src", "prebuild:docs": "rimraf ./docs/api", - "build:docs": "yarn workspace @webex/plugin-presence run build:docs && yarn workspace @webex/calling run build:docs && yarn workspace @webex/byods run build:docs && yarn workspace @webex/plugin-cc run build:docs && yarn workspace @webex/plugin-encryption run build:docs && documentation build --config documentation/config.yml --format html --output ./docs/api --github ./packages/webex/src/index.js ./packages/@webex/plugin-*/src/index.[tj]s --babel=./babel.config.json", + "build:docs": "yarn workspace @webex/plugin-presence run build:docs && yarn workspace @webex/calling run build:docs && yarn workspace @webex/byods run build:docs && yarn workspace @webex/contact-center run build:docs && yarn workspace @webex/plugin-encryption run build:docs && documentation build --config documentation/config.yml --format html --output ./docs/api --github ./packages/webex/src/index.js ./packages/@webex/plugin-*/src/index.[tj]s --babel=./babel.config.json", "check-karma-output": "./scripts/analyze-output.sh", "build:package": "node ./tooling/index.js build", "changelog:generate": "npx standard-changelog", diff --git a/packages/@webex/contact-center/Archive.zip b/packages/@webex/contact-center/Archive.zip new file mode 100644 index 0000000000000000000000000000000000000000..997af1d7327891891d8d6d4bab404f7899e18b9c GIT binary patch literal 518286 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�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

Y;!Q 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 o-I=J0q9X$18p$%Y(B", @@ -20,7 +20,7 @@ "repository": { "type": "git", "url": "https://github.com/webex/webex-js-sdk.git", - "directory": "packages/@webex/plugin-cc" + "directory": "packages/@webex/contact-center" }, "engines": { "node": ">=20.x" diff --git a/packages/@webex/plugin-cc/src/cc.ts b/packages/@webex/contact-center/src/cc.ts similarity index 100% rename from packages/@webex/plugin-cc/src/cc.ts rename to packages/@webex/contact-center/src/cc.ts diff --git a/packages/@webex/plugin-cc/src/config.ts b/packages/@webex/contact-center/src/config.ts similarity index 100% rename from packages/@webex/plugin-cc/src/config.ts rename to packages/@webex/contact-center/src/config.ts diff --git a/packages/@webex/plugin-cc/src/constants.ts b/packages/@webex/contact-center/src/constants.ts similarity index 100% rename from packages/@webex/plugin-cc/src/constants.ts rename to packages/@webex/contact-center/src/constants.ts diff --git a/packages/@webex/plugin-cc/src/index.ts b/packages/@webex/contact-center/src/index.ts similarity index 100% rename from packages/@webex/plugin-cc/src/index.ts rename to packages/@webex/contact-center/src/index.ts diff --git a/packages/@webex/plugin-cc/src/logger-proxy.ts b/packages/@webex/contact-center/src/logger-proxy.ts similarity index 100% rename from packages/@webex/plugin-cc/src/logger-proxy.ts rename to packages/@webex/contact-center/src/logger-proxy.ts diff --git a/packages/@webex/plugin-cc/src/metrics/MetricsManager.ts b/packages/@webex/contact-center/src/metrics/MetricsManager.ts similarity index 100% rename from packages/@webex/plugin-cc/src/metrics/MetricsManager.ts rename to packages/@webex/contact-center/src/metrics/MetricsManager.ts diff --git a/packages/@webex/plugin-cc/src/metrics/behavioral-events.ts b/packages/@webex/contact-center/src/metrics/behavioral-events.ts similarity index 100% rename from packages/@webex/plugin-cc/src/metrics/behavioral-events.ts rename to packages/@webex/contact-center/src/metrics/behavioral-events.ts diff --git a/packages/@webex/plugin-cc/src/metrics/constants.ts b/packages/@webex/contact-center/src/metrics/constants.ts similarity index 100% rename from packages/@webex/plugin-cc/src/metrics/constants.ts rename to packages/@webex/contact-center/src/metrics/constants.ts diff --git a/packages/@webex/plugin-cc/src/services/WebCallingService.ts b/packages/@webex/contact-center/src/services/WebCallingService.ts similarity index 100% rename from packages/@webex/plugin-cc/src/services/WebCallingService.ts rename to packages/@webex/contact-center/src/services/WebCallingService.ts diff --git a/packages/@webex/plugin-cc/src/services/agent/index.ts b/packages/@webex/contact-center/src/services/agent/index.ts similarity index 100% rename from packages/@webex/plugin-cc/src/services/agent/index.ts rename to packages/@webex/contact-center/src/services/agent/index.ts diff --git a/packages/@webex/plugin-cc/src/services/agent/types.ts b/packages/@webex/contact-center/src/services/agent/types.ts similarity index 100% rename from packages/@webex/plugin-cc/src/services/agent/types.ts rename to packages/@webex/contact-center/src/services/agent/types.ts diff --git a/packages/@webex/plugin-cc/src/services/config/Util.ts b/packages/@webex/contact-center/src/services/config/Util.ts similarity index 100% rename from packages/@webex/plugin-cc/src/services/config/Util.ts rename to packages/@webex/contact-center/src/services/config/Util.ts diff --git a/packages/@webex/plugin-cc/src/services/config/constants.ts b/packages/@webex/contact-center/src/services/config/constants.ts similarity index 100% rename from packages/@webex/plugin-cc/src/services/config/constants.ts rename to packages/@webex/contact-center/src/services/config/constants.ts diff --git a/packages/@webex/plugin-cc/src/services/config/index.ts b/packages/@webex/contact-center/src/services/config/index.ts similarity index 100% rename from packages/@webex/plugin-cc/src/services/config/index.ts rename to packages/@webex/contact-center/src/services/config/index.ts diff --git a/packages/@webex/plugin-cc/src/services/config/types.ts b/packages/@webex/contact-center/src/services/config/types.ts similarity index 100% rename from packages/@webex/plugin-cc/src/services/config/types.ts rename to packages/@webex/contact-center/src/services/config/types.ts diff --git a/packages/@webex/plugin-cc/src/services/constants.ts b/packages/@webex/contact-center/src/services/constants.ts similarity index 100% rename from packages/@webex/plugin-cc/src/services/constants.ts rename to packages/@webex/contact-center/src/services/constants.ts diff --git a/packages/@webex/plugin-cc/src/services/core/Err.ts b/packages/@webex/contact-center/src/services/core/Err.ts similarity index 100% rename from packages/@webex/plugin-cc/src/services/core/Err.ts rename to packages/@webex/contact-center/src/services/core/Err.ts diff --git a/packages/@webex/plugin-cc/src/services/core/GlobalTypes.ts b/packages/@webex/contact-center/src/services/core/GlobalTypes.ts similarity index 100% rename from packages/@webex/plugin-cc/src/services/core/GlobalTypes.ts rename to packages/@webex/contact-center/src/services/core/GlobalTypes.ts diff --git a/packages/@webex/plugin-cc/src/services/core/Utils.ts b/packages/@webex/contact-center/src/services/core/Utils.ts similarity index 100% rename from packages/@webex/plugin-cc/src/services/core/Utils.ts rename to packages/@webex/contact-center/src/services/core/Utils.ts diff --git a/packages/@webex/plugin-cc/src/services/core/WebexRequest.ts b/packages/@webex/contact-center/src/services/core/WebexRequest.ts similarity index 100% rename from packages/@webex/plugin-cc/src/services/core/WebexRequest.ts rename to packages/@webex/contact-center/src/services/core/WebexRequest.ts diff --git a/packages/@webex/plugin-cc/src/services/core/aqm-reqs.ts b/packages/@webex/contact-center/src/services/core/aqm-reqs.ts similarity index 100% rename from packages/@webex/plugin-cc/src/services/core/aqm-reqs.ts rename to packages/@webex/contact-center/src/services/core/aqm-reqs.ts diff --git a/packages/@webex/plugin-cc/src/services/core/constants.ts b/packages/@webex/contact-center/src/services/core/constants.ts similarity index 100% rename from packages/@webex/plugin-cc/src/services/core/constants.ts rename to packages/@webex/contact-center/src/services/core/constants.ts diff --git a/packages/@webex/plugin-cc/src/services/core/types.ts b/packages/@webex/contact-center/src/services/core/types.ts similarity index 100% rename from packages/@webex/plugin-cc/src/services/core/types.ts rename to packages/@webex/contact-center/src/services/core/types.ts diff --git a/packages/@webex/plugin-cc/src/services/core/websocket/WebSocketManager.ts b/packages/@webex/contact-center/src/services/core/websocket/WebSocketManager.ts similarity index 100% rename from packages/@webex/plugin-cc/src/services/core/websocket/WebSocketManager.ts rename to packages/@webex/contact-center/src/services/core/websocket/WebSocketManager.ts diff --git a/packages/@webex/plugin-cc/src/services/core/websocket/connection-service.ts b/packages/@webex/contact-center/src/services/core/websocket/connection-service.ts similarity index 100% rename from packages/@webex/plugin-cc/src/services/core/websocket/connection-service.ts rename to packages/@webex/contact-center/src/services/core/websocket/connection-service.ts diff --git a/packages/@webex/plugin-cc/src/services/core/websocket/keepalive.worker.js b/packages/@webex/contact-center/src/services/core/websocket/keepalive.worker.js similarity index 100% rename from packages/@webex/plugin-cc/src/services/core/websocket/keepalive.worker.js rename to packages/@webex/contact-center/src/services/core/websocket/keepalive.worker.js diff --git a/packages/@webex/plugin-cc/src/services/core/websocket/types.ts b/packages/@webex/contact-center/src/services/core/websocket/types.ts similarity index 100% rename from packages/@webex/plugin-cc/src/services/core/websocket/types.ts rename to packages/@webex/contact-center/src/services/core/websocket/types.ts diff --git a/packages/@webex/plugin-cc/src/services/index.ts b/packages/@webex/contact-center/src/services/index.ts similarity index 100% rename from packages/@webex/plugin-cc/src/services/index.ts rename to packages/@webex/contact-center/src/services/index.ts diff --git a/packages/@webex/plugin-cc/src/services/task/Task.ts b/packages/@webex/contact-center/src/services/task/Task.ts similarity index 100% rename from packages/@webex/plugin-cc/src/services/task/Task.ts rename to packages/@webex/contact-center/src/services/task/Task.ts diff --git a/packages/@webex/plugin-cc/src/services/task/TaskFactory.ts b/packages/@webex/contact-center/src/services/task/TaskFactory.ts similarity index 100% rename from packages/@webex/plugin-cc/src/services/task/TaskFactory.ts rename to packages/@webex/contact-center/src/services/task/TaskFactory.ts diff --git a/packages/@webex/plugin-cc/src/services/task/TaskManager.ts b/packages/@webex/contact-center/src/services/task/TaskManager.ts similarity index 100% rename from packages/@webex/plugin-cc/src/services/task/TaskManager.ts rename to packages/@webex/contact-center/src/services/task/TaskManager.ts diff --git a/packages/@webex/plugin-cc/src/services/task/constants.ts b/packages/@webex/contact-center/src/services/task/constants.ts similarity index 96% rename from packages/@webex/plugin-cc/src/services/task/constants.ts rename to packages/@webex/contact-center/src/services/task/constants.ts index aadafe9ed78..b9ccc28f670 100644 --- a/packages/@webex/plugin-cc/src/services/task/constants.ts +++ b/packages/@webex/contact-center/src/services/task/constants.ts @@ -1,6 +1,6 @@ /** * Constants for Task Service - * @module @webex/plugin-cc/services/task/constants + * @module @webex/contact-center/services/task/constants * @ignore */ diff --git a/packages/@webex/plugin-cc/src/services/task/contact.ts b/packages/@webex/contact-center/src/services/task/contact.ts similarity index 100% rename from packages/@webex/plugin-cc/src/services/task/contact.ts rename to packages/@webex/contact-center/src/services/task/contact.ts diff --git a/packages/@webex/plugin-cc/src/services/task/dialer.ts b/packages/@webex/contact-center/src/services/task/dialer.ts similarity index 100% rename from packages/@webex/plugin-cc/src/services/task/dialer.ts rename to packages/@webex/contact-center/src/services/task/dialer.ts diff --git a/packages/@webex/plugin-cc/src/services/task/digital/Digital.ts b/packages/@webex/contact-center/src/services/task/digital/Digital.ts similarity index 100% rename from packages/@webex/plugin-cc/src/services/task/digital/Digital.ts rename to packages/@webex/contact-center/src/services/task/digital/Digital.ts diff --git a/packages/@webex/plugin-cc/src/services/task/index.ts b/packages/@webex/contact-center/src/services/task/index.ts similarity index 100% rename from packages/@webex/plugin-cc/src/services/task/index.ts rename to packages/@webex/contact-center/src/services/task/index.ts diff --git a/packages/@webex/plugin-cc/src/services/task/types.ts b/packages/@webex/contact-center/src/services/task/types.ts similarity index 100% rename from packages/@webex/plugin-cc/src/services/task/types.ts rename to packages/@webex/contact-center/src/services/task/types.ts diff --git a/packages/@webex/plugin-cc/src/services/task/voice/Voice.ts b/packages/@webex/contact-center/src/services/task/voice/Voice.ts similarity index 100% rename from packages/@webex/plugin-cc/src/services/task/voice/Voice.ts rename to packages/@webex/contact-center/src/services/task/voice/Voice.ts diff --git a/packages/@webex/plugin-cc/src/services/task/voice/WebRTC.ts b/packages/@webex/contact-center/src/services/task/voice/WebRTC.ts similarity index 100% rename from packages/@webex/plugin-cc/src/services/task/voice/WebRTC.ts rename to packages/@webex/contact-center/src/services/task/voice/WebRTC.ts diff --git a/packages/@webex/plugin-cc/src/types.ts b/packages/@webex/contact-center/src/types.ts similarity index 100% rename from packages/@webex/plugin-cc/src/types.ts rename to packages/@webex/contact-center/src/types.ts diff --git a/packages/@webex/plugin-cc/src/webex-config.ts b/packages/@webex/contact-center/src/webex-config.ts similarity index 100% rename from packages/@webex/plugin-cc/src/webex-config.ts rename to packages/@webex/contact-center/src/webex-config.ts diff --git a/packages/@webex/plugin-cc/src/webex.js b/packages/@webex/contact-center/src/webex.js similarity index 97% rename from packages/@webex/plugin-cc/src/webex.js rename to packages/@webex/contact-center/src/webex.js index 821fdb1e51e..443a7825352 100644 --- a/packages/@webex/plugin-cc/src/webex.js +++ b/packages/@webex/contact-center/src/webex.js @@ -53,13 +53,13 @@ const Webex = WebexCore.extend({ * The merged configuration governs various SDK behaviors, such as authorization, logging, and CC-specific settings. * * @example Basic Usage - * import Webex from '@webex/plugin-cc'; + * import Webex from '@webex/contact-center'; * * // Initialize Webex SDK with default configuration * const webex = Webex.init(); * * @example Custom Configuration - * import Webex from '@webex/plugin-cc'; + * import Webex from '@webex/contact-center'; * * const customConfig = { * logger: { diff --git a/packages/@webex/plugin-cc/test/unit/spec/cc.ts b/packages/@webex/contact-center/test/unit/spec/cc.ts similarity index 100% rename from packages/@webex/plugin-cc/test/unit/spec/cc.ts rename to packages/@webex/contact-center/test/unit/spec/cc.ts diff --git a/packages/@webex/plugin-cc/test/unit/spec/metrics/MetricsManager.ts b/packages/@webex/contact-center/test/unit/spec/metrics/MetricsManager.ts similarity index 100% rename from packages/@webex/plugin-cc/test/unit/spec/metrics/MetricsManager.ts rename to packages/@webex/contact-center/test/unit/spec/metrics/MetricsManager.ts diff --git a/packages/@webex/plugin-cc/test/unit/spec/metrics/behavioral-events.ts b/packages/@webex/contact-center/test/unit/spec/metrics/behavioral-events.ts similarity index 100% rename from packages/@webex/plugin-cc/test/unit/spec/metrics/behavioral-events.ts rename to packages/@webex/contact-center/test/unit/spec/metrics/behavioral-events.ts diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/WebCallingService.ts b/packages/@webex/contact-center/test/unit/spec/services/WebCallingService.ts similarity index 100% rename from packages/@webex/plugin-cc/test/unit/spec/services/WebCallingService.ts rename to packages/@webex/contact-center/test/unit/spec/services/WebCallingService.ts diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/agent/index.ts b/packages/@webex/contact-center/test/unit/spec/services/agent/index.ts similarity index 100% rename from packages/@webex/plugin-cc/test/unit/spec/services/agent/index.ts rename to packages/@webex/contact-center/test/unit/spec/services/agent/index.ts diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/config/index.ts b/packages/@webex/contact-center/test/unit/spec/services/config/index.ts similarity index 100% rename from packages/@webex/plugin-cc/test/unit/spec/services/config/index.ts rename to packages/@webex/contact-center/test/unit/spec/services/config/index.ts diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/core/Utils.ts b/packages/@webex/contact-center/test/unit/spec/services/core/Utils.ts similarity index 100% rename from packages/@webex/plugin-cc/test/unit/spec/services/core/Utils.ts rename to packages/@webex/contact-center/test/unit/spec/services/core/Utils.ts diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/core/WebexRequest.ts b/packages/@webex/contact-center/test/unit/spec/services/core/WebexRequest.ts similarity index 100% rename from packages/@webex/plugin-cc/test/unit/spec/services/core/WebexRequest.ts rename to packages/@webex/contact-center/test/unit/spec/services/core/WebexRequest.ts diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/core/aqm-reqs.ts b/packages/@webex/contact-center/test/unit/spec/services/core/aqm-reqs.ts similarity index 100% rename from packages/@webex/plugin-cc/test/unit/spec/services/core/aqm-reqs.ts rename to packages/@webex/contact-center/test/unit/spec/services/core/aqm-reqs.ts diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/core/websocket/WebSocketManager.ts b/packages/@webex/contact-center/test/unit/spec/services/core/websocket/WebSocketManager.ts similarity index 100% rename from packages/@webex/plugin-cc/test/unit/spec/services/core/websocket/WebSocketManager.ts rename to packages/@webex/contact-center/test/unit/spec/services/core/websocket/WebSocketManager.ts diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/core/websocket/connection-service.ts b/packages/@webex/contact-center/test/unit/spec/services/core/websocket/connection-service.ts similarity index 100% rename from packages/@webex/plugin-cc/test/unit/spec/services/core/websocket/connection-service.ts rename to packages/@webex/contact-center/test/unit/spec/services/core/websocket/connection-service.ts diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/task/Task.ts b/packages/@webex/contact-center/test/unit/spec/services/task/Task.ts similarity index 100% rename from packages/@webex/plugin-cc/test/unit/spec/services/task/Task.ts rename to packages/@webex/contact-center/test/unit/spec/services/task/Task.ts diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/task/TaskFactory.ts b/packages/@webex/contact-center/test/unit/spec/services/task/TaskFactory.ts similarity index 100% rename from packages/@webex/plugin-cc/test/unit/spec/services/task/TaskFactory.ts rename to packages/@webex/contact-center/test/unit/spec/services/task/TaskFactory.ts diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/task/TaskManager.ts b/packages/@webex/contact-center/test/unit/spec/services/task/TaskManager.ts similarity index 100% rename from packages/@webex/plugin-cc/test/unit/spec/services/task/TaskManager.ts rename to packages/@webex/contact-center/test/unit/spec/services/task/TaskManager.ts diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/task/contact.ts b/packages/@webex/contact-center/test/unit/spec/services/task/contact.ts similarity index 100% rename from packages/@webex/plugin-cc/test/unit/spec/services/task/contact.ts rename to packages/@webex/contact-center/test/unit/spec/services/task/contact.ts diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/task/dialer.ts b/packages/@webex/contact-center/test/unit/spec/services/task/dialer.ts similarity index 100% rename from packages/@webex/plugin-cc/test/unit/spec/services/task/dialer.ts rename to packages/@webex/contact-center/test/unit/spec/services/task/dialer.ts diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/task/digital/Digital.ts b/packages/@webex/contact-center/test/unit/spec/services/task/digital/Digital.ts similarity index 100% rename from packages/@webex/plugin-cc/test/unit/spec/services/task/digital/Digital.ts rename to packages/@webex/contact-center/test/unit/spec/services/task/digital/Digital.ts diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/task/index.ts b/packages/@webex/contact-center/test/unit/spec/services/task/index.ts similarity index 100% rename from packages/@webex/plugin-cc/test/unit/spec/services/task/index.ts rename to packages/@webex/contact-center/test/unit/spec/services/task/index.ts diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/task/voice/Voice.ts b/packages/@webex/contact-center/test/unit/spec/services/task/voice/Voice.ts similarity index 100% rename from packages/@webex/plugin-cc/test/unit/spec/services/task/voice/Voice.ts rename to packages/@webex/contact-center/test/unit/spec/services/task/voice/Voice.ts diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/task/voice/WebRTC.ts b/packages/@webex/contact-center/test/unit/spec/services/task/voice/WebRTC.ts similarity index 100% rename from packages/@webex/plugin-cc/test/unit/spec/services/task/voice/WebRTC.ts rename to packages/@webex/contact-center/test/unit/spec/services/task/voice/WebRTC.ts diff --git a/packages/@webex/plugin-cc/tsconfig.json b/packages/@webex/contact-center/tsconfig.json similarity index 100% rename from packages/@webex/plugin-cc/tsconfig.json rename to packages/@webex/contact-center/tsconfig.json diff --git a/packages/@webex/plugin-cc/typedoc.json b/packages/@webex/contact-center/typedoc.json similarity index 94% rename from packages/@webex/plugin-cc/typedoc.json rename to packages/@webex/contact-center/typedoc.json index f2c24b177e9..f3ee87c5b9b 100644 --- a/packages/@webex/plugin-cc/typedoc.json +++ b/packages/@webex/contact-center/typedoc.json @@ -24,7 +24,7 @@ "categoryOrder": ["Core", "Services", "Classes", "Interfaces", "Enum", "Types", "*"], "navigationLinks": { "Home": "/", - "NPM": "http://npmjs.com/package/@webex/plugin-cc", + "NPM": "http://npmjs.com/package/@webex/contact-center", "GitHub": "https://github.com/webex/webex-js-sdk" }, "searchInComments": true, diff --git a/packages/@webex/plugin-cc/typedoc.md b/packages/@webex/contact-center/typedoc.md similarity index 90% rename from packages/@webex/plugin-cc/typedoc.md rename to packages/@webex/contact-center/typedoc.md index 527901a395d..7e60db33174 100644 --- a/packages/@webex/plugin-cc/typedoc.md +++ b/packages/@webex/contact-center/typedoc.md @@ -1,6 +1,6 @@ # Webex JS SDK: Contact Center Plugin -Welcome to the documentation for the **@webex/plugin-cc** package, part of the [Webex JS SDK](https://github.com/webex/webex-js-sdk). This plugin provides APIs and utilities for integrating Webex Contact Center features into your JavaScript applications. +Welcome to the documentation for the **@webex/contact-center** package, part of the [Webex JS SDK](https://github.com/webex/webex-js-sdk). This plugin provides APIs and utilities for integrating Webex Contact Center features into your JavaScript applications. ## Overview @@ -11,13 +11,13 @@ Welcome to the documentation for the **@webex/plugin-cc** package, part of the [ ## Getting Started ```bash -npm install @webex/plugin-cc +npm install @webex/contact-center ``` Add the plugin to your Webex SDK instance: ```js -import Webex from '@webex/plugin-cc'; +import Webex from '@webex/contact-center'; // Initialize Webex SDK with default configuration const webex = Webex.init(); @@ -26,7 +26,7 @@ const webex = Webex.init(); Or, initialize with a custom configuration: ```js -import Webex from '@webex/plugin-cc'; +import Webex from '@webex/contact-center'; const customConfig = { logger: { diff --git a/packages/webex/package.json b/packages/webex/package.json index 069275693e1..e49a049d2a6 100644 --- a/packages/webex/package.json +++ b/packages/webex/package.json @@ -68,6 +68,7 @@ "@babel/runtime-corejs2": "^7.14.8", "@webex/calling": "workspace:*", "@webex/common": "workspace:*", + "@webex/contact-center": "workspace:*", "@webex/internal-plugin-calendar": "workspace:*", "@webex/internal-plugin-device": "workspace:*", "@webex/internal-plugin-llm": "workspace:*", @@ -77,7 +78,6 @@ "@webex/internal-plugin-voicea": "workspace:*", "@webex/plugin-attachment-actions": "workspace:*", "@webex/plugin-authorization": "workspace:*", - "@webex/plugin-cc": "workspace:*", "@webex/plugin-device-manager": "workspace:*", "@webex/plugin-encryption": "workspace:*", "@webex/plugin-logger": "workspace:*", diff --git a/packages/webex/src/webex.js b/packages/webex/src/webex.js index 99445c337b5..fde383a8644 100644 --- a/packages/webex/src/webex.js +++ b/packages/webex/src/webex.js @@ -28,7 +28,7 @@ require('@webex/plugin-teams'); require('@webex/plugin-team-memberships'); require('@webex/plugin-webhooks'); require('@webex/plugin-encryption'); -require('@webex/plugin-cc'); +require('@webex/contact-center'); const merge = require('lodash/merge'); const WebexCore = require('@webex/webex-core').default; diff --git a/webpack.config.js b/webpack.config.js index 1ab4426a2c7..f6839779e9c 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -55,7 +55,7 @@ module.exports = (env = {NODE_ENV: process.env.NODE_ENV || 'production'}) => ({ }, }, 'contact-center': { - import: `${path.resolve(__dirname)}/packages/@webex/plugin-cc/src/webex.js`, + import: `${path.resolve(__dirname)}/packages/@webex/contact-center/src/webex.js`, library: { name: 'Webex', type: 'umd', diff --git a/yarn.lock b/yarn.lock index abc5b26c4f0..11ae8ab0d0c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7966,6 +7966,45 @@ __metadata: languageName: unknown linkType: soft +"@webex/contact-center@workspace:*, @webex/contact-center@workspace:packages/@webex/contact-center": + version: 0.0.0-use.local + resolution: "@webex/contact-center@workspace:packages/@webex/contact-center" + dependencies: + "@babel/preset-typescript": 7.22.11 + "@types/jest": 27.4.1 + "@types/platform": 1.3.4 + "@typescript-eslint/eslint-plugin": 5.38.1 + "@typescript-eslint/parser": 5.38.1 + "@webex/babel-config-legacy": "workspace:*" + "@webex/calling": "workspace:*" + "@webex/eslint-config-legacy": "workspace:*" + "@webex/internal-plugin-mercury": "workspace:*" + "@webex/internal-plugin-metrics": "workspace:*" + "@webex/internal-plugin-support": "workspace:*" + "@webex/jest-config-legacy": "workspace:*" + "@webex/legacy-tools": "workspace:*" + "@webex/plugin-authorization": "workspace:*" + "@webex/plugin-logger": "workspace:*" + "@webex/test-helper-mock-webex": "workspace:*" + "@webex/webex-core": "workspace:*" + eslint: ^8.24.0 + eslint-config-airbnb-base: 15.0.0 + eslint-config-prettier: 8.3.0 + eslint-import-resolver-typescript: 2.4.0 + eslint-plugin-import: 2.25.3 + eslint-plugin-jsdoc: 38.0.4 + eslint-plugin-prettier: 4.0.0 + eslint-plugin-tsdoc: 0.2.14 + jest: 27.5.1 + jest-html-reporters: 3.0.11 + jest-junit: 13.0.0 + lodash: ^4.17.21 + prettier: 2.5.1 + typedoc: ^0.25.0 + typescript: 4.9.5 + languageName: unknown + linkType: soft + "@webex/env-config-legacy@workspace:*, @webex/env-config-legacy@workspace:packages/legacy/env": version: 0.0.0-use.local resolution: "@webex/env-config-legacy@workspace:packages/legacy/env" @@ -9084,45 +9123,6 @@ __metadata: languageName: unknown linkType: soft -"@webex/plugin-cc@workspace:*, @webex/plugin-cc@workspace:packages/@webex/plugin-cc": - version: 0.0.0-use.local - resolution: "@webex/plugin-cc@workspace:packages/@webex/plugin-cc" - dependencies: - "@babel/preset-typescript": 7.22.11 - "@types/jest": 27.4.1 - "@types/platform": 1.3.4 - "@typescript-eslint/eslint-plugin": 5.38.1 - "@typescript-eslint/parser": 5.38.1 - "@webex/babel-config-legacy": "workspace:*" - "@webex/calling": "workspace:*" - "@webex/eslint-config-legacy": "workspace:*" - "@webex/internal-plugin-mercury": "workspace:*" - "@webex/internal-plugin-metrics": "workspace:*" - "@webex/internal-plugin-support": "workspace:*" - "@webex/jest-config-legacy": "workspace:*" - "@webex/legacy-tools": "workspace:*" - "@webex/plugin-authorization": "workspace:*" - "@webex/plugin-logger": "workspace:*" - "@webex/test-helper-mock-webex": "workspace:*" - "@webex/webex-core": "workspace:*" - eslint: ^8.24.0 - eslint-config-airbnb-base: 15.0.0 - eslint-config-prettier: 8.3.0 - eslint-import-resolver-typescript: 2.4.0 - eslint-plugin-import: 2.25.3 - eslint-plugin-jsdoc: 38.0.4 - eslint-plugin-prettier: 4.0.0 - eslint-plugin-tsdoc: 0.2.14 - jest: 27.5.1 - jest-html-reporters: 3.0.11 - jest-junit: 13.0.0 - lodash: ^4.17.21 - prettier: 2.5.1 - typedoc: ^0.25.0 - typescript: 4.9.5 - languageName: unknown - linkType: soft - "@webex/plugin-device-manager@workspace:*, @webex/plugin-device-manager@workspace:packages/@webex/plugin-device-manager": version: 0.0.0-use.local resolution: "@webex/plugin-device-manager@workspace:packages/@webex/plugin-device-manager" @@ -34272,6 +34272,7 @@ __metadata: "@webex/babel-config-legacy": "workspace:*" "@webex/calling": "workspace:*" "@webex/common": "workspace:*" + "@webex/contact-center": "workspace:*" "@webex/eslint-config-legacy": "workspace:*" "@webex/internal-plugin-calendar": "workspace:*" "@webex/internal-plugin-device": "workspace:*" @@ -34284,7 +34285,6 @@ __metadata: "@webex/legacy-tools": "workspace:*" "@webex/plugin-attachment-actions": "workspace:*" "@webex/plugin-authorization": "workspace:*" - "@webex/plugin-cc": "workspace:*" "@webex/plugin-device-manager": "workspace:*" "@webex/plugin-encryption": "workspace:*" "@webex/plugin-logger": "workspace:*" From 13ffc924a564e2869043a6a37f618073ba7e67a5 Mon Sep 17 00:00:00 2001 From: Adhwaith Menon <111346225+adhmenon@users.noreply.github.com> Date: Thu, 11 Dec 2025 19:52:11 +0530 Subject: [PATCH 8/8] feat(contact-center): merge-next-into-task-refactor (#4605) Co-authored-by: xiaoyang <13363075304@163.com> Co-authored-by: rsarika <95286093+rsarika@users.noreply.github.com> Co-authored-by: Rajesh Kumar <131742425+rarajes2@users.noreply.github.com> Co-authored-by: akulakum <74420487+akulakum@users.noreply.github.com> Co-authored-by: Marcin Co-authored-by: Lisa Smith <82507773+lismith2-cisco@users.noreply.github.com> Co-authored-by: Shreyas Sharma <72344404+Shreyas281299@users.noreply.github.com> Co-authored-by: Ravi Chandra Sekhar Sarika Co-authored-by: Kesari3008 <65543166+Kesari3008@users.noreply.github.com> Co-authored-by: WeijuanShao Co-authored-by: sokn-sys <58249052+sokn-sys@users.noreply.github.com> Co-authored-by: CormacGCisco Co-authored-by: Coread Co-authored-by: junhao3268 <142187938+junhao3268@users.noreply.github.com> Co-authored-by: mickelr <121160648+mickelr@users.noreply.github.com> Co-authored-by: vskygk <49182080+vskygk@users.noreply.github.com> Co-authored-by: vicwan Co-authored-by: JudyZhu <120536178+JudyZhuHz@users.noreply.github.com> Co-authored-by: chrisadubois Co-authored-by: venky-mediboina <91044509+venky-mediboina@users.noreply.github.com> Co-authored-by: Bharath Balan <62698609+bhabalan@users.noreply.github.com> Co-authored-by: Hem Dutt Co-authored-by: Hem Dutt --- README.md | 1 + docs/samples/contact-center/app.js | 91 +- docs/samples/contact-center/index.html | 9 + jest.config.js | 1 + karma-ng.conf.js | 13 +- packages/@webex/contact-center/Archive.zip | Bin 518286 -> 0 bytes packages/@webex/contact-center/src/cc.ts | 23 +- .../@webex/contact-center/src/constants.ts | 7 +- packages/@webex/contact-center/src/index.ts | 1 + .../src/metrics/behavioral-events.ts | 12 + .../contact-center/src/metrics/constants.ts | 5 + .../src/services/config/constants.ts | 1 + .../src/services/config/types.ts | 6 +- .../contact-center/src/services/core/Utils.ts | 219 +- .../src/services/core/aqm-reqs.ts | 5 - .../src/services/core/constants.ts | 16 + .../core/websocket/WebSocketManager.ts | 4 - .../contact-center/src/services/task/Task.ts | 152 +- .../src/services/task/TaskFactory.ts | 18 +- .../src/services/task/TaskManager.ts | 409 +- .../src/services/task/TaskUtils.ts | 150 +- .../src/services/task/constants.ts | 2 + .../src/services/task/digital/Digital.ts | 11 +- .../contact-center/src/services/task/index.ts | 193 +- .../contact-center/src/services/task/types.ts | 323 +- .../src/services/task/voice/Voice.ts | 6 +- .../src/services/task/voice/WebRTC.ts | 8 +- packages/@webex/contact-center/src/types.ts | 1 + .../contact-center/test/unit/spec/cc.ts | 97 +- .../unit/spec/metrics/behavioral-events.ts | 14 + .../test/unit/spec/services/core/Utils.ts | 362 +- .../test/unit/spec/services/task/Task.ts | 62 + .../unit/spec/services/task/TaskManager.ts | 905 +- .../test/unit/spec/services/task/TaskUtils.ts | 320 +- .../test/unit/spec/services/task/index.ts | 508 +- .../unit/spec/services/task/voice/Voice.ts | 20 +- .../src/ai-assistant.ts | 14 +- .../src/constants.ts | 1 + .../internal-plugin-ai-assistant/src/types.ts | 1 + .../internal-plugin-ai-assistant/src/utils.ts | 29 + .../test/unit/data/messages.ts | 61 + .../test/unit/spec/ai-assistant.ts | 76 +- .../@webex/internal-plugin-dss/src/dss.ts | 52 + .../test/integration/spec/dss.js | 69 + .../internal-plugin-dss/test/unit/spec/dss.ts | 261 +- .../internal-plugin-mercury/src/mercury.js | 24 +- .../test/unit/spec/mercury.js | 10 + .../call-diagnostic-metrics.ts | 7 +- .../call-diagnostic-metrics.ts | 2 +- .../@webex/internal-plugin-task/.eslintrc.js | 6 + .../@webex/internal-plugin-task/README.md | 77 + .../internal-plugin-task/babel.config.js | 3 + .../internal-plugin-task/jest.config.js | 3 + .../@webex/internal-plugin-task/package.json | 55 + packages/@webex/internal-plugin-task/process | 1 + .../@webex/internal-plugin-task/src/config.js | 7 + .../internal-plugin-task/src/constants.js | 2 + .../src/helpers/decrypt.helper.js | 58 + .../src/helpers/encrypt.helper.js | 50 + .../@webex/internal-plugin-task/src/index.js | 22 + .../@webex/internal-plugin-task/src/task.js | 224 + .../test/integration/spec/task.js | 87 + .../test/unit/spec/helpers/decrypt.helper.js | 93 + .../test/unit/spec/helpers/encrypt.helper.js | 43 + .../test/unit/spec/task.js | 194 + .../internal-plugin-voicea/src/constants.ts | 2 + .../internal-plugin-voicea/src/voicea.ts | 22 +- .../test/unit/spec/voicea.js | 54 +- packages/@webex/media-helpers/package.json | 4 +- .../@webex/media-helpers/src/webrtc-core.ts | 2 + .../src/authorization.js | 14 + .../test/unit/spec/authorization.js | 68 +- packages/@webex/plugin-meetings/package.json | 4 +- .../src/common/errors/webex-errors.ts | 19 + .../@webex/plugin-meetings/src/constants.ts | 16 +- .../src/hashTree/hashTreeParser.ts | 146 + .../plugin-meetings/src/hashTree/types.ts | 20 + packages/@webex/plugin-meetings/src/index.ts | 2 + .../plugin-meetings/src/locus-info/index.ts | 619 +- .../plugin-meetings/src/locus-info/types.ts | 46 + .../@webex/plugin-meetings/src/media/index.ts | 6 + .../plugin-meetings/src/meeting/index.ts | 88 +- .../plugin-meetings/src/meeting/util.ts | 1 + .../plugin-meetings/src/meetings/index.ts | 155 +- .../plugin-meetings/src/metrics/constants.ts | 2 + .../src/reachability/clusterReachability.ts | 397 +- .../src/reachability/reachability.types.ts | 16 +- .../reachabilityPeerConnection.ts | 416 + .../test/unit/spec/locus-info/index.js | 577 +- .../test/unit/spec/media/index.ts | 149 +- .../test/unit/spec/meeting/index.js | 272 +- .../test/unit/spec/meeting/utils.js | 79 +- .../test/unit/spec/meetings/index.js | 99 +- .../spec/reachability/clusterReachability.ts | 419 +- packages/@webex/webex-core/src/config.js | 8 + packages/@webex/webex-core/src/index.js | 11 +- .../webex-core/src/interceptors/redirect.js | 4 + .../src/lib/services-v2/constants.js | 21 - .../webex-core/src/lib/services-v2/index.js | 23 - .../webex-core/src/lib/services-v2/index.ts | 1 - .../lib/services-v2/interceptors/hostmap.js | 36 - .../services-v2/interceptors/server-error.js | 48 - .../lib/services-v2/interceptors/service.js | 101 - .../webex-core/src/lib/services-v2/metrics.js | 4 - .../src/lib/services-v2/service-catalog.js | 455 - .../src/lib/services-v2/service-catalog.ts | 8 +- .../src/lib/services-v2/service-fed-ramp.js | 5 - .../src/lib/services-v2/service-url.js | 124 - .../src/lib/services-v2/services-v2.js | 971 -- .../src/lib/services-v2/services-v2.ts | 65 +- .../webex-core/src/lib/services-v2/types.ts | 8 + .../webex-core/src/lib/services/services.js | 56 +- .../test/fixtures/host-catalog-v2.js | 247 - .../test/fixtures/host-catalog-v2.ts | 2 +- .../spec/services-v2/service-catalog.js | 4 +- .../spec/services-v2/services-v2.js | 44 +- .../integration/spec/services/services.js | 44 +- .../test/unit/spec/interceptors/redirect.js | 98 + .../test/unit/spec/services-v2/services-v2.js | 564 - .../test/unit/spec/services-v2/services-v2.ts | 118 + .../test/unit/spec/services/services.js | 80 + packages/calling/package.json | 2 +- .../src/CallHistory/CallHistory.test.ts | 106 +- .../calling/src/CallHistory/CallHistory.ts | 43 +- .../CallSettings/UcmBackendConnector.test.ts | 111 +- .../src/CallSettings/UcmBackendConnector.ts | 41 +- .../WxCallBackendConnector.test.ts | 5 +- .../CallSettings/WxCallBackendConnector.ts | 37 +- .../src/CallingClient/CallingClient.test.ts | 193 +- .../src/CallingClient/CallingClient.ts | 338 +- .../src/CallingClient/calling/call.test.ts | 209 +- .../calling/src/CallingClient/calling/call.ts | 199 +- .../CallingClient/calling/callManager.test.ts | 2 +- .../calling/src/CallingClient/constants.ts | 3 + .../registration/register.test.ts | 333 +- .../CallingClient/registration/register.ts | 114 +- .../src/CallingClient/registration/types.ts | 7 + .../CallingClient/registration/webWorker.ts | 4 +- .../registration/webWorkerStr.ts | 4 +- .../src/Contacts/ContactsClient.test.ts | 19 +- .../calling/src/Contacts/ContactsClient.ts | 53 +- packages/calling/src/Events/impl/index.ts | 2 +- packages/calling/src/Logger/index.test.ts | 3 +- packages/calling/src/Logger/index.ts | 7 +- packages/calling/src/SDKConnector/types.ts | 16 + .../BroadworksBackendConnector.test.ts | 8 + .../Voicemail/BroadworksBackendConnector.ts | 34 +- .../src/Voicemail/UcmBackendConnector.ts | 23 +- packages/calling/src/Voicemail/Voicemail.ts | 126 +- .../Voicemail/WxCallBackendConnector.test.ts | 66 +- .../src/Voicemail/WxCallBackendConnector.ts | 47 +- packages/calling/src/common/Utils.test.ts | 242 +- packages/calling/src/common/Utils.ts | 59 +- packages/calling/src/common/constants.ts | 2 + packages/calling/src/common/testUtil.ts | 1 + packages/config/esbuild/static/cli.js | 3 + packages/webex/package.json | 1 + packages/webex/src/webex.js | 1 + yarn.lock | 11202 +++++++--------- 159 files changed, 14771 insertions(+), 11266 deletions(-) delete mode 100644 packages/@webex/contact-center/Archive.zip create mode 100644 packages/@webex/internal-plugin-dss/test/integration/spec/dss.js create mode 100644 packages/@webex/internal-plugin-task/.eslintrc.js create mode 100644 packages/@webex/internal-plugin-task/README.md create mode 100644 packages/@webex/internal-plugin-task/babel.config.js create mode 100644 packages/@webex/internal-plugin-task/jest.config.js create mode 100644 packages/@webex/internal-plugin-task/package.json create mode 100644 packages/@webex/internal-plugin-task/process create mode 100644 packages/@webex/internal-plugin-task/src/config.js create mode 100644 packages/@webex/internal-plugin-task/src/constants.js create mode 100644 packages/@webex/internal-plugin-task/src/helpers/decrypt.helper.js create mode 100644 packages/@webex/internal-plugin-task/src/helpers/encrypt.helper.js create mode 100644 packages/@webex/internal-plugin-task/src/index.js create mode 100644 packages/@webex/internal-plugin-task/src/task.js create mode 100644 packages/@webex/internal-plugin-task/test/integration/spec/task.js create mode 100644 packages/@webex/internal-plugin-task/test/unit/spec/helpers/decrypt.helper.js create mode 100644 packages/@webex/internal-plugin-task/test/unit/spec/helpers/encrypt.helper.js create mode 100644 packages/@webex/internal-plugin-task/test/unit/spec/task.js create mode 100644 packages/@webex/plugin-meetings/src/hashTree/hashTreeParser.ts create mode 100644 packages/@webex/plugin-meetings/src/hashTree/types.ts create mode 100644 packages/@webex/plugin-meetings/src/locus-info/types.ts create mode 100644 packages/@webex/plugin-meetings/src/reachability/reachabilityPeerConnection.ts delete mode 100644 packages/@webex/webex-core/src/lib/services-v2/constants.js delete mode 100644 packages/@webex/webex-core/src/lib/services-v2/index.js delete mode 100644 packages/@webex/webex-core/src/lib/services-v2/interceptors/hostmap.js delete mode 100644 packages/@webex/webex-core/src/lib/services-v2/interceptors/server-error.js delete mode 100644 packages/@webex/webex-core/src/lib/services-v2/interceptors/service.js delete mode 100644 packages/@webex/webex-core/src/lib/services-v2/metrics.js delete mode 100644 packages/@webex/webex-core/src/lib/services-v2/service-catalog.js delete mode 100644 packages/@webex/webex-core/src/lib/services-v2/service-fed-ramp.js delete mode 100644 packages/@webex/webex-core/src/lib/services-v2/service-url.js delete mode 100644 packages/@webex/webex-core/src/lib/services-v2/services-v2.js delete mode 100644 packages/@webex/webex-core/test/fixtures/host-catalog-v2.js delete mode 100644 packages/@webex/webex-core/test/unit/spec/services-v2/services-v2.js diff --git a/README.md b/README.md index f9df3959c91..e5b36f2e71e 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![license: Cisco](https://img.shields.io/badge/License-Cisco-blueviolet?style=flat-square)](https://github.com/webex/webex-js-sdk/blob/master/LICENSE) ![state: Stable](https://img.shields.io/badge/State-Stable-blue?style=flat-square) ![scope: Public](https://img.shields.io/badge/Scope-Public-darkgreen?style=flat-square) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/webex/webex-js-sdk) This project is designed as a mono-repository for all publicly-provided JavaScript packages from Cisco's Webex Developer Experience team. These packages consist of mostly API-related modules that allow for seamless integration with the collection of services that belong to the Webex platform. diff --git a/docs/samples/contact-center/app.js b/docs/samples/contact-center/app.js index 93e626e9363..3f95afedeba 100644 --- a/docs/samples/contact-center/app.js +++ b/docs/samples/contact-center/app.js @@ -71,6 +71,7 @@ const initiateConsultDialog = document.querySelector('#initiate-consult-dialog') const agentMultiLoginAlert = document.querySelector('#agentMultiLoginAlert'); const consultTransferBtn = document.querySelector('#consult-transfer'); const transferElm = document.getElementById('transfer'); +const transferOptionsElm = document.querySelector('#transfer-options'); const conferenceToggleBtn = document.querySelector('#conference-toggle'); const timerElm = document.querySelector('#timerDisplay'); const engageElm = document.querySelector('#engageWidget'); @@ -649,6 +650,16 @@ async function handleQueueConsult(consultPayload) { } +// Function to toggle transfer options visibility +function toggleTransferOptions() { + if (transferOptionsElm.style.display === 'none') { + transferOptionsElm.style.display = 'block'; + onTransferTypeSelectionChanged(); // Refresh the destination options + } else { + transferOptionsElm.style.display = 'none'; + } +} + // Function to initiate transfer async function initiateTransfer() { const destinationType = document.querySelector('#transfer-destination-type').value; @@ -667,6 +678,7 @@ async function initiateTransfer() { try { await currentTask.transfer(transferPayload); console.log('Transfer initiated successfully'); + transferOptionsElm.style.display = 'none'; } catch (error) { console.error('Failed to initiate transfer', error); alert('Failed to initiate transfer'); @@ -692,7 +704,7 @@ async function initiateConsultTransfer() { if (currentTask.data.isConferenceInProgress) { await currentTask.transferConference(); } else { - await currentTask.consultTransfer(consultTransferPayload); + await currentTask.transfer(consultTransferPayload); console.log('Consult transfer initiated successfully'); } } catch (error) { @@ -919,13 +931,16 @@ async function startOutdial() { try { console.log('Making an outdial call'); console.log('Destination:', destination); - console.log('Selected ANI:', selectedAni || 'None selected'); + console.log('Selected ANI:', selectedAni || 'None selected, using default ANI'); // Use selected ANI as the origin parameter if (selectedAni) { await webex.cc.startOutdial(destination, selectedAni); console.log('Outdial call initiated successfully with ANI:', selectedAni); - } + } else { + await webex.cc.startOutdial(destination); + console.log('Outdial call initiated successfully with default ANI'); + } } catch (error) { console.error('Failed to initiate outdial call', error); @@ -1013,6 +1028,12 @@ function registerTaskListeners(task) { showAgentStatePopup(reason); }); + task.on('task:outdialFailed', (reason) => { + updateTaskList(); + console.info('Outdial failed with reason:', reason); + showOutdialFailedPopup(reason); + }); + task.on('task:wrappedup', updateTaskList); // Update the task list UI to have latest tasks // Conference event listeners - Simplified approach @@ -1128,12 +1149,12 @@ function getConsultStatus(task) { const participant = Object.values(participants).find(p => p.pType === 'Agent' && p.id === agentId); if (state === 'consult') { - if (participant && participant.isConsulted) { + if ((participant && participant.isConsulted )|| isSecondaryEpDnAgent(task)) { return 'beingConsulted'; } return 'consultInitiated'; } else if (state === 'consulting') { - if (participant && participant.isConsulted) { + if ((participant && participant.isConsulted) || isSecondaryEpDnAgent(task)) { return 'beingConsultedAccepted'; } return 'consultAccepted'; @@ -1819,6 +1840,31 @@ function showAgentStatePopup(reason) { popup.classList.remove('hidden'); } +function showOutdialFailedPopup(reason) { + const outdialFailedReasonText = document.getElementById('outdialFailedReasonText'); + + // Set the reason text based on the reason + if (reason === 'CUSTOMER_BUSY') { + outdialFailedReasonText.innerText = 'Customer is busy'; + } else if (reason === 'NO_ANSWER') { + outdialFailedReasonText.innerText = 'No answer from customer'; + } else if (reason === 'CALL_FAILED') { + outdialFailedReasonText.innerText = 'Call failed'; + } else if (reason === 'INVALID_NUMBER') { + outdialFailedReasonText.innerText = 'Invalid phone number'; + } else { + outdialFailedReasonText.innerText = `Outdial failed: ${reason}`; + } + + const outdialFailedPopup = document.getElementById('outdialFailedPopup'); + outdialFailedPopup.classList.remove('hidden'); +} + +function closeOutdialFailedPopup() { + const outdialFailedPopup = document.getElementById('outdialFailedPopup'); + outdialFailedPopup.classList.add('hidden'); +} + async function renderBuddyAgents() { buddyAgentsDropdownElm.innerHTML = ''; // Clear previous options const buddyAgentsDropdownNodes = await fetchBuddyAgentsNodeList(); @@ -1955,14 +2001,14 @@ function expandAll() { function holdResumeCall() { if (holdResumeElm.innerText === 'Hold') { holdResumeElm.disabled = true; - currentTask.hold().then(() => { + currentTask.holdResume().then(() => { console.info('Call held successfully'); }).catch((error) => { console.error('Failed to hold the call', error); }); } else { holdResumeElm.disabled = true; - currentTask.resume().then(() => { + currentTask.holdResume().then(() => { console.info('Call resumed successfully'); }).catch((error) => { console.error('Failed to resume the call', error); @@ -2127,6 +2173,7 @@ function renderTaskList(taskList) { const isNew = isIncomingTask(task, agentId); const isTelephony = task.data.interaction.mediaType === 'telephony'; const isBrowserPhone = agentDeviceType === 'BROWSER'; + const isAutoAnswering = task.data.isAutoAnswering || false; // Determine which buttons to show const showAcceptButton = isNew && (isBrowserPhone || !isTelephony); @@ -2136,8 +2183,8 @@ function renderTaskList(taskList) { taskElement.innerHTML = `

${callerDisplay}

- ${showAcceptButton ? `` : ''} - ${showDeclineButton ? `` : ''} + ${showAcceptButton ? `` : ''} + ${showDeclineButton ? `` : ''}

`; @@ -2208,24 +2255,40 @@ function renderTaskList(taskList) { function enableAnswerDeclineButtons(task) { const callerDisplay = task.data.interaction?.callAssociatedDetails?.ani; const isNew = isIncomingTask(task, agentId); - const chatAndSocial = ['chat', 'social']; + const isAutoAnswering = task.data.isAutoAnswering || false; + const chatAndSocial = ['chat', 'social']; + if (task.data.interaction.mediaType === 'telephony') { if (agentDeviceType === 'BROWSER') { - answerElm.disabled = !isNew; - declineElm.disabled = !isNew; + // Disable buttons if auto-answering or not new + answerElm.disabled = !isNew || isAutoAnswering; + declineElm.disabled = !isNew || isAutoAnswering; incomingDetailsElm.innerText = `Call from ${callerDisplay}`; + + // Log auto-answer status for debugging + if (isAutoAnswering) { + console.log('✅ Auto-answer in progress for task:', task.data.interactionId); + } } else { incomingDetailsElm.innerText = `Call from ${callerDisplay}...please answer on the endpoint where the agent's extension is registered`; } } else if (chatAndSocial.includes(task.data.interaction.mediaType)) { - answerElm.disabled = !isNew; + answerElm.disabled = !isNew || isAutoAnswering; declineElm.disabled = true; incomingDetailsElm.innerText = `Chat from ${callerDisplay}`; + + if (isAutoAnswering) { + console.log('✅ Auto-answer in progress for task:', task.data.interactionId); + } } else if (task.data.interaction.mediaType === 'email') { - answerElm.disabled = !isNew; + answerElm.disabled = !isNew || isAutoAnswering; declineElm.disabled = true; incomingDetailsElm.innerText = `Email from ${callerDisplay}`; + + if (isAutoAnswering) { + console.log('✅ Auto-answer in progress for task:', task.data.interactionId); + } } } diff --git a/docs/samples/contact-center/index.html b/docs/samples/contact-center/index.html index e975b8bf0a5..01801b5b2a1 100644 --- a/docs/samples/contact-center/index.html +++ b/docs/samples/contact-center/index.html @@ -311,6 +311,15 @@

Set the state of the agent

+ + +