From dd98dc6d86d4f44f1b2fc717f91acd43ad947cfb Mon Sep 17 00:00:00 2001 From: Rajesh Kumar <131742425+rarajes2@users.noreply.github.com> Date: Wed, 12 Feb 2025 19:45:19 +0530 Subject: [PATCH] feat(plugin-cc): add task sync on reload (#4089) --- docs/samples/contact-center/app.js | 78 +++++++++++++++++++ packages/@webex/plugin-cc/src/cc.ts | 9 ++- .../plugin-cc/src/services/config/types.ts | 1 + .../src/services/task/TaskManager.ts | 5 ++ .../plugin-cc/src/services/task/types.ts | 1 + .../@webex/plugin-cc/test/unit/spec/cc.ts | 71 +++++++++-------- .../unit/spec/services/task/TaskManager.ts | 58 +++++++++----- 7 files changed, 167 insertions(+), 56 deletions(-) diff --git a/docs/samples/contact-center/app.js b/docs/samples/contact-center/app.js index c2bbee7ce42..911ca1c7de2 100644 --- a/docs/samples/contact-center/app.js +++ b/docs/samples/contact-center/app.js @@ -187,6 +187,8 @@ function updateButtonsPostEndCall() { showConsultButton() disableTransferControls(); consultTabBtn.disabled = true; + incomingDetailsElm.innerText = ''; + pauseResumeRecordingElm.innerText = 'Pause Recording'; } function showInitiateConsultDialog() { @@ -614,6 +616,10 @@ function register() { incomingCallListener.dispatchEvent(taskEvents); }); + webex.cc.on('task:hydrate', (currentTask) => { + handleTaskHydrate(currentTask); + }); + webex.cc.on('agent:stateChange', (data) => { if (data && typeof data === 'object' && data.type === 'AgentStateChangeSuccess') { const DEFAULT_CODE = '0'; // Default code when no aux code is present @@ -631,6 +637,78 @@ function register() { } +function handleTaskHydrate(currentTask) { + task = currentTask; + + if (!task || !task.data || !task.data.interaction) { + console.error('task:hydrate --> No task data found.'); + alert('task:hydrate --> No task data found.'); + + return; + } + + const { data, webCallingService } = task; + const { interaction, mediaResourceId, agentId } = data; + const { + state, + isTerminated, + media, + participants, + callAssociatedDetails, + callProcessingDetails + } = interaction; + + if (isTerminated) { + // wrapup + if (state === 'wrapUp' && !participants[agentId].isWrappedUp) { + wrapupCodesDropdownElm.disabled = false; + wrapupElm.disabled = false; + } + + return; + } + + // answer & decline incoming calls + const callerDisplay = callAssociatedDetails?.ani; + if (webCallingService.loginOption === 'BROWSER') { + answerElm.disabled = false; + declineElm.disabled = false; + + incomingDetailsElm.innerText = `Call from ${callerDisplay}`; + } else { + incomingDetailsElm.innerText = `Call from ${callerDisplay}...please answer on the endpoint where the agent's extension is registered`; + } + + // end button + const hasParticipants = Object.keys(participants).length > 1; + endElm.disabled = !hasParticipants; + + // hold/resume call + const isHold = media && media[mediaResourceId] && media[mediaResourceId].isHold; + holdResumeElm.disabled = isTerminated; + holdResumeElm.innerText = isHold ? 'Resume' : 'Hold'; + + if (callProcessingDetails) { + const { pauseResumeEnabled, isPaused } = callProcessingDetails; + + // pause/resume recording + pauseResumeRecordingElm.disabled = !pauseResumeEnabled; + pauseResumeRecordingElm.innerText = isPaused === 'true' ? 'Resume Recording' : 'Pause Recording'; + } + + // end consult, consult transfer buttons + const { consultMediaResourceId, destAgentId, destinationType } = data; + if (consultMediaResourceId && destAgentId && destinationType) { + const destination = participants[destAgentId]; + destinationTypeDropdown.value = destinationType; + consultDestinationInput.value = destination.dn; + + consultTabBtn.style.display = 'none'; + endConsultBtn.style.display = 'inline-block'; + consultTransferBtn.style.display = 'inline-block'; + } +} + function populateWrapupCodesDropdown() { wrapupCodesDropdownElm.innerHTML = ''; // Clear previous options wrapupCodes.forEach((code) => { diff --git a/packages/@webex/plugin-cc/src/cc.ts b/packages/@webex/plugin-cc/src/cc.ts index 9aafde4cb41..17126c47b6e 100644 --- a/packages/@webex/plugin-cc/src/cc.ts +++ b/packages/@webex/plugin-cc/src/cc.ts @@ -79,11 +79,17 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter this.trigger(TASK_EVENTS.TASK_INCOMING, task); }; + private handleTaskHydrate = (task: ITask) => { + // @ts-ignore + this.trigger(TASK_EVENTS.TASK_HYDRATE, task); + }; + /** * An Incoming Call listener. */ private incomingTaskListener() { this.taskManager.on(TASK_EVENTS.TASK_INCOMING, this.handleIncomingTask); + this.taskManager.on(TASK_EVENTS.TASK_HYDRATE, this.handleTaskHydrate); } /** @@ -197,7 +203,6 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter this.services.webSocketManager.on('message', this.handleWebSocketMessage); this.incomingTaskListener(); - this.taskManager.registerIncomingCallEvent(); return loginResponse; } catch (error) { @@ -225,6 +230,7 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter this.taskManager.unregisterIncomingCallEvent(); this.taskManager.off(TASK_EVENTS.TASK_INCOMING, this.handleIncomingTask); + this.taskManager.off(TASK_EVENTS.TASK_HYDRATE, this.handleTaskHydrate); this.services.webSocketManager.off('message', this.handleWebSocketMessage); return logoutResponse; @@ -352,7 +358,6 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter // To handle re-registration of event listeners on silent relogin this.incomingTaskListener(); - this.taskManager.registerIncomingCallEvent(); if (lastStateChangeReason === 'agent-wss-disconnect') { LoggerProxy.info( diff --git a/packages/@webex/plugin-cc/src/services/config/types.ts b/packages/@webex/plugin-cc/src/services/config/types.ts index 84571a66aec..fecf9365d14 100644 --- a/packages/@webex/plugin-cc/src/services/config/types.ts +++ b/packages/@webex/plugin-cc/src/services/config/types.ts @@ -55,6 +55,7 @@ export const CC_EVENTS = { AGENT_WRAPUP: 'AgentWrapup', AGENT_WRAPPEDUP: 'AgentWrappedUp', AGENT_WRAPUP_FAILED: 'AgentWrapupFailed', + AGENT_CONTACT: 'AgentContact', } as const; export type WelcomeEvent = { diff --git a/packages/@webex/plugin-cc/src/services/task/TaskManager.ts b/packages/@webex/plugin-cc/src/services/task/TaskManager.ts index 482fef6a598..95459d9a5e4 100644 --- a/packages/@webex/plugin-cc/src/services/task/TaskManager.ts +++ b/packages/@webex/plugin-cc/src/services/task/TaskManager.ts @@ -65,6 +65,11 @@ export default class TaskManager extends EventEmitter { const payload = JSON.parse(event); if (payload.data) { switch (payload.data.type) { + case CC_EVENTS.AGENT_CONTACT: + this.currentTask = new Task(this.contact, this.webCallingService, payload.data); + this.taskCollection[payload.data.interactionId] = this.currentTask; + this.emit(TASK_EVENTS.TASK_HYDRATE, this.currentTask); + break; case CC_EVENTS.AGENT_CONTACT_RESERVED: this.currentTask = new Task(this.contact, this.webCallingService, payload.data); this.currentTask.data = {...this.currentTask.data, isConsulted: false}; // Ensure isConsulted prop exists diff --git a/packages/@webex/plugin-cc/src/services/task/types.ts b/packages/@webex/plugin-cc/src/services/task/types.ts index f0295034312..ba321bf539c 100644 --- a/packages/@webex/plugin-cc/src/services/task/types.ts +++ b/packages/@webex/plugin-cc/src/services/task/types.ts @@ -49,6 +49,7 @@ export const TASK_EVENTS = { TASK_END: 'task:end', TASK_WRAPUP: 'task:wrapup', TASK_REJECT: 'task:rejected', + TASK_HYDRATE: 'task:hydrate', } as const; export type TASK_EVENTS = Enum; diff --git a/packages/@webex/plugin-cc/test/unit/spec/cc.ts b/packages/@webex/plugin-cc/test/unit/spec/cc.ts index 64cbd2dbd92..297137b0850 100644 --- a/packages/@webex/plugin-cc/test/unit/spec/cc.ts +++ b/packages/@webex/plugin-cc/test/unit/spec/cc.ts @@ -21,8 +21,7 @@ import {CC_FILE, AGENT_STATE_CHANGE, AGENT_MULTI_LOGIN} from '../../../src/const import '../../../__mocks__/workerMock'; import {Profile} from '../../../src/services/config/types'; import TaskManager from '../../../src/services/task/TaskManager'; -import { TASK_EVENTS } from '../../../src/services/task/types'; - +import {TASK_EVENTS} from '../../../src/services/task/types'; jest.mock('../../../src/logger-proxy', () => ({ __esModule: true, @@ -47,7 +46,6 @@ describe('webex.cc', () => { let mockTaskManager; let mockWebSocketManager; - beforeEach(() => { webex = MockWebex({ children: { @@ -85,7 +83,7 @@ describe('webex.cc', () => { end: jest.fn(), wrapup: jest.fn(), cancelTask: jest.fn(), - cancelCtq: jest.fn() + cancelCtq: jest.fn(), }; // Mock Services instance @@ -104,7 +102,7 @@ describe('webex.cc', () => { connectionService: { on: jest.fn(), }, - contact: mockContact + contact: mockContact, }; mockTaskManager = { @@ -121,8 +119,8 @@ describe('webex.cc', () => { on: jest.fn(), off: jest.fn(), emit: jest.fn(), - unregisterIncomingCallEvent: jest.fn() - } + unregisterIncomingCallEvent: jest.fn(), + }; jest.spyOn(Services, 'getInstance').mockReturnValue(mockServicesInstance); jest.spyOn(TaskManager, 'getTaskManager').mockReturnValue(mockTaskManager); @@ -338,10 +336,10 @@ describe('webex.cc', () => { }, }); expect(configSpy).toHaveBeenCalled(); - expect(LoggerProxy.log).toHaveBeenCalledWith( - 'agent config is fetched successfully', - {module: CC_FILE, method: 'mockConstructor'} - ); + expect(LoggerProxy.log).toHaveBeenCalledWith('agent config is fetched successfully', { + module: CC_FILE, + method: 'mockConstructor', + }); expect(reloadSpy).not.toHaveBeenCalled(); expect(result).toEqual(mockAgentProfile); }); @@ -410,32 +408,28 @@ describe('webex.cc', () => { expect(emitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_INCOMING, mockTask); // Verify message event listener - const messageCallback = mockWebSocketManager.on.mock.calls.find(call => call[0] === 'message')[1]; + const messageCallback = mockWebSocketManager.on.mock.calls.find( + (call) => call[0] === 'message' + )[1]; const agentStateChangeEventData = { type: CC_EVENTS.AGENT_STATE_CHANGE, - data: { some: 'data' }, + data: {some: 'data'}, }; const agentMultiLoginEventData = { type: CC_EVENTS.AGENT_MULTI_LOGIN, data: {some: 'data'}, - } + }; // Simulate receiving a message event messageCallback(JSON.stringify(agentStateChangeEventData)); - expect(ccEmitSpy).toHaveBeenCalledWith( - AGENT_STATE_CHANGE, - agentStateChangeEventData.data - ); + expect(ccEmitSpy).toHaveBeenCalledWith(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_MULTI_LOGIN, agentMultiLoginEventData.data); }); it('should login successfully with other LoginOption', async () => { @@ -507,7 +501,14 @@ describe('webex.cc', () => { expect(stationLogoutMock).toHaveBeenCalledWith({data: data}); expect(mockTaskManager.unregisterIncomingCallEvent).toHaveBeenCalledWith(); - expect(mockTaskManager.off).toHaveBeenCalledWith(TASK_EVENTS.TASK_INCOMING, 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(result).toEqual(response); }); @@ -567,6 +568,15 @@ describe('webex.cc', () => { {module: CC_FILE, method: 'stationReLogin'} ); }); + + 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', () => { @@ -778,10 +788,6 @@ describe('webex.cc', () => { webex.cc.webCallingService, 'registerWebCallingLine' ); - const registerIncomingCallEventSpy = jest.spyOn( - webex.cc.taskManager, - 'registerIncomingCallEvent' - ); const incomingTaskListenerSpy = jest.spyOn(webex.cc, 'incomingTaskListener'); const webSocketManagerOnSpy = jest.spyOn(webex.cc.services.webSocketManager, 'on'); await webex.cc['silentRelogin'](); @@ -801,9 +807,12 @@ describe('webex.cc', () => { expect(webex.cc.agentConfig.lastStateChangeTimestamp).toStrictEqual(date); // it should be updated with the new timestamp of setAgentState response expect(webex.cc.agentConfig.deviceType).toBe(LoginOption.BROWSER); expect(registerWebCallingLineSpy).toHaveBeenCalled(); - expect(registerIncomingCallEventSpy).toHaveBeenCalled(); expect(incomingTaskListenerSpy).toHaveBeenCalled(); expect(webSocketManagerOnSpy).toHaveBeenCalledWith('message', expect.any(Function)); + expect(mockTaskManager.on).toHaveBeenCalledWith( + TASK_EVENTS.TASK_HYDRATE, + expect.any(Function) + ); }); it('should handle AGENT_NOT_FOUND error silently', async () => { @@ -902,11 +911,7 @@ describe('webex.cc', () => { it('should set up connectionLost and message event listener', () => { webex.cc.setupEventListeners(); - expect(connectionServiceOnSpy).toHaveBeenCalledWith( - 'connectionLost', - expect.any(Function) - ); + expect(connectionServiceOnSpy).toHaveBeenCalledWith('connectionLost', expect.any(Function)); }); }); - }); 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 3d8a1020f90..55b113e8114 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,8 +9,7 @@ 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 {wrap} from 'module'; describe('TaskManager', () => { let mockCall; @@ -80,8 +79,8 @@ describe('TaskManager', () => { accept: jest.fn(), decline: jest.fn(), updateTaskData: jest.fn(), - data: taskDataMock - } + data: taskDataMock, + }; taskManager.call = mockCall; }); @@ -281,8 +280,8 @@ describe('TaskManager', () => { type: CC_EVENTS.CONTACT_ENDED, agentId: '723a8ffb-a26e-496d-b14a-ff44fb83b64f', eventTime: 1733211616959, - eventType: "RoutingMessage", - interaction: {"state": "new"}, + eventType: 'RoutingMessage', + interaction: {state: 'new'}, interactionId: taskId, orgId: '6ecef209-9a34-4ed1-a07a-7ddd1dbe925a', trackingId: '575c0ec2-618c-42af-a61c-53aeb0a221ee', @@ -298,7 +297,10 @@ describe('TaskManager', () => { expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_END, {wrapupRequired: false}); expect(webCallListenerSpy).toHaveBeenCalledWith(); - expect(callOffSpy).toHaveBeenCalledWith(CALL_EVENT_KEYS.REMOTE_MEDIA, callOffSpy.mock.calls[0][1]); + 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 @@ -306,6 +308,21 @@ describe('TaskManager', () => { expect(offSpy).toHaveBeenCalledWith(LINE_EVENTS.INCOMING_CALL, offSpy.mock.calls[1][1]); }); + it('should emit TASK_HYDRATE event on AGENT_CONTACT event', () => { + const payload = { + data: { + ...initalPayload.data, + type: CC_EVENTS.AGENT_CONTACT, + }, + }; + + const taskEmitSpy = jest.spyOn(taskManager, 'emit'); + webSocketManagerMock.emit('message', JSON.stringify(payload)); + + expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_HYDRATE, taskManager.currentTask); + expect(taskManager.taskCollection[payload.data.interactionId]).toBe(taskManager.currentTask); + }); + it('should emit TASK_END event on AGENT_WRAPUP event', () => { webSocketManagerMock.emit('message', JSON.stringify(initalPayload)); @@ -517,7 +534,6 @@ describe('TaskManager', () => { }, }; - webSocketManagerMock.emit('message', JSON.stringify(initalPayload)); // Always spy on the updated task object after CONTACT_RESERVED is emitted @@ -550,32 +566,32 @@ describe('TaskManager', () => { const reservedPayload = { data: { type: CC_EVENTS.AGENT_CONTACT_RESERVED, - agentId: "723a8ffb-a26e-496d-b14a-ff44fb83b64f", + agentId: '723a8ffb-a26e-496d-b14a-ff44fb83b64f', eventTime: 1733211616959, - eventType: "RoutingMessage", + eventType: 'RoutingMessage', interaction: {}, interactionId: taskId, - orgId: "6ecef209-9a34-4ed1-a07a-7ddd1dbe925a", - trackingId: "575c0ec2-618c-42af-a61c-53aeb0a221ee", + 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', }, }; - + webSocketManagerMock.emit('message', JSON.stringify(reservedPayload)); - + const ronaPayload = { data: { type: CC_EVENTS.AGENT_CONTACT_OFFER_RONA, - agentId: "723a8ffb-a26e-496d-b14a-ff44fb83b64f", + agentId: '723a8ffb-a26e-496d-b14a-ff44fb83b64f', eventTime: 1733211616959, - eventType: "RoutingMessage", + eventType: 'RoutingMessage', interaction: {}, interactionId: taskId, - orgId: "6ecef209-9a34-4ed1-a07a-7ddd1dbe925a", - trackingId: "575c0ec2-618c-42af-a61c-53aeb0a221ee", + 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', @@ -583,12 +599,12 @@ describe('TaskManager', () => { reason: 'USER_REJECTED', }, }; - + taskManager.taskCollection[taskId] = taskManager.currentTask; const taskEmitSpy = jest.spyOn(taskManager.currentTask, 'emit'); - + webSocketManagerMock.emit('message', JSON.stringify(ronaPayload)); - + expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_REJECT, ronaPayload.data.reason); expect(taskManager.getTask(taskId)).toBeUndefined(); });