diff --git a/packages/@webex/contact-center/src/cc.ts b/packages/@webex/contact-center/src/cc.ts index d38cf978b43..6049a8e8e7b 100644 --- a/packages/@webex/contact-center/src/cc.ts +++ b/packages/@webex/contact-center/src/cc.ts @@ -40,7 +40,7 @@ import { METHODS, } from './constants'; import {AGENT_STATE_AVAILABLE, AGENT_STATE_AVAILABLE_ID} from './services/config/constants'; -import {AGENT, WEB_RTC_PREFIX} from './services/constants'; +import {AGENT, RTD_SUBSCRIBE_API, SUBSCRIBE_API, WEB_RTC_PREFIX} from './services/constants'; import Services from './services'; import WebexRequest from './services/core/WebexRequest'; import LoggerProxy from './logger-proxy'; @@ -372,7 +372,8 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter this.apiAIAssistant, this.services.contact, this.webCallingService, - this.services.webSocketManager + this.services.webSocketManager, + this.services.rtdWebSocketManager ); this.incomingTaskListener(); @@ -577,6 +578,9 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter if (!this.services.webSocketManager.isSocketClosed) { this.services.webSocketManager.close(false, 'Unregistering the SDK'); } + if (!this.services.rtdWebSocketManager.isSocketClosed) { + this.services.rtdWebSocketManager.close(false, 'Unregistering the SDK'); + } // Clear any cached agent configuration this.agentConfig = null; @@ -706,6 +710,7 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter return this.services.webSocketManager .initWebSocket({ body: this.getConnectionConfig(), + resource: SUBSCRIBE_API, }) .then(async (data: WelcomeEvent) => { const agentId = data.agentId; @@ -729,7 +734,35 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter this.taskManager.setAgentId(this.agentConfig.agentId); this.taskManager.setWebRtcEnabled(this.agentConfig.webRtcEnabled); this.apiAIAssistant.setAIFeatureFlags(this.agentConfig.aiFeature); - + /** + * TODO: We need to re-check this condition if this websocket is only for realtime transcripts + * or other AI Assistant features will also use the same. + * If the latter is true, we need to update this condition. + */ + if (this.agentConfig.aiFeature?.realtimeTranscripts?.enable) { + LoggerProxy.info('Connecting to RTD websocket', { + module: CC_FILE, + method: METHODS.CONNECT_WEBSOCKET, + }); + + this.services.rtdWebSocketManager + .initWebSocket({ + body: this.getConnectionConfig(), + resource: RTD_SUBSCRIBE_API, + }) + .then(() => { + LoggerProxy.log('RTD websocket connected successfully', { + module: CC_FILE, + method: METHODS.CONNECT_WEBSOCKET, + }); + }) + .catch((error) => { + LoggerProxy.error(`Error connecting to RTD websocket ${error}`, { + module: CC_FILE, + method: METHODS.CONNECT_WEBSOCKET, + }); + }); + } if ( this.agentConfig.webRtcEnabled && this.agentConfig.loginVoiceOptions.includes(LoginOption.BROWSER) diff --git a/packages/@webex/contact-center/src/services/constants.ts b/packages/@webex/contact-center/src/services/constants.ts index f27cdcb2e35..a1c1c3f7168 100644 --- a/packages/@webex/contact-center/src/services/constants.ts +++ b/packages/@webex/contact-center/src/services/constants.ts @@ -58,6 +58,13 @@ export const AGENT = 'agent'; * @ignore */ export const SUBSCRIBE_API = 'v1/notification/subscribe'; +/** + * API path for realtime transcription subscription. + * @type {string} + * @public + * @ignore + */ +export const RTD_SUBSCRIBE_API = 'v1/realtime/subscribe'; /** * API path for agent login. diff --git a/packages/@webex/contact-center/src/services/core/websocket/WebSocketManager.ts b/packages/@webex/contact-center/src/services/core/websocket/WebSocketManager.ts index af4cdf0180d..0ef84d7e920 100644 --- a/packages/@webex/contact-center/src/services/core/websocket/WebSocketManager.ts +++ b/packages/@webex/contact-center/src/services/core/websocket/WebSocketManager.ts @@ -1,6 +1,6 @@ import EventEmitter from 'events'; import {WebexSDK, SubscribeRequest, HTTP_METHODS} from '../../../types'; -import {SUBSCRIBE_API, WCC_API_GATEWAY} from '../../constants'; +import {WCC_API_GATEWAY} from '../../constants'; import {ConnectionLostDetails} from './types'; import {CC_EVENTS, SubscribeResponse, WelcomeResponse} from '../../config/types'; import LoggerProxy from '../../../logger-proxy'; @@ -44,9 +44,12 @@ export class WebSocketManager extends EventEmitter { this.keepaliveWorker = new Worker(URL.createObjectURL(workerScriptBlob)); } - async initWebSocket(options: {body: SubscribeRequest}): Promise { - const connectionConfig = options.body; - await this.register(connectionConfig); + async initWebSocket(options: { + body: SubscribeRequest; + resource: string; + }): Promise { + const {body, resource} = options; + await this.register(body, resource); return new Promise((resolve, reject) => { this.welcomePromiseResolve = resolve; @@ -76,11 +79,11 @@ export class WebSocketManager extends EventEmitter { this.isConnectionLost = event.isConnectionLost; } - private async register(connectionConfig: SubscribeRequest) { + private async register(connectionConfig: SubscribeRequest, resource: string) { try { const subscribeResponse: SubscribeResponse = await this.webex.request({ service: WCC_API_GATEWAY, - resource: SUBSCRIBE_API, + resource, method: HTTP_METHODS.POST, body: connectionConfig, }); diff --git a/packages/@webex/contact-center/src/services/core/websocket/connection-service.ts b/packages/@webex/contact-center/src/services/core/websocket/connection-service.ts index a65359f9de1..f7448b67a63 100644 --- a/packages/@webex/contact-center/src/services/core/websocket/connection-service.ts +++ b/packages/@webex/contact-center/src/services/core/websocket/connection-service.ts @@ -10,6 +10,7 @@ import { } from '../constants'; import {CONNECTION_SERVICE_FILE} from '../../../constants'; import {SubscribeRequest} from '../../../types'; +import {SUBSCRIBE_API} from '../../constants'; export class ConnectionService extends EventEmitter { private connectionProp: ConnectionProp = { @@ -124,7 +125,10 @@ export class ConnectionService extends EventEmitter { }); const onlineStatus = navigator.onLine; if (onlineStatus) { - await this.webSocketManager.initWebSocket({body: this.subscribeRequest}); + await this.webSocketManager.initWebSocket({ + body: this.subscribeRequest, + resource: SUBSCRIBE_API, + }); await this.clearTimerOnRestoreFailed(); this.isSocketReconnected = true; } else { diff --git a/packages/@webex/contact-center/src/services/index.ts b/packages/@webex/contact-center/src/services/index.ts index 9a8e188d9dc..6957a5d2d16 100644 --- a/packages/@webex/contact-center/src/services/index.ts +++ b/packages/@webex/contact-center/src/services/index.ts @@ -25,6 +25,8 @@ export default class Services { public readonly dialer: ReturnType; /** WebSocket manager for handling real-time communications */ public readonly webSocketManager: WebSocketManager; + /** RTD WebSocket manager for handling real-time transcription */ + public readonly rtdWebSocketManager: WebSocketManager; /** Connection service for managing websocket connections */ public readonly connectionService: ConnectionService; /** Singleton instance of the Services class */ @@ -39,6 +41,7 @@ export default class Services { constructor(options: {webex: WebexSDK; connectionConfig: SubscribeRequest}) { const {webex, connectionConfig} = options; this.webSocketManager = new WebSocketManager({webex}); + this.rtdWebSocketManager = new WebSocketManager({webex}); const aqmReq = new AqmReqs(this.webSocketManager); this.config = new AgentConfigService(); this.agent = routingAgent(aqmReq); diff --git a/packages/@webex/contact-center/src/services/task/TaskManager.ts b/packages/@webex/contact-center/src/services/task/TaskManager.ts index f7d86d6c55f..25b00d403e8 100644 --- a/packages/@webex/contact-center/src/services/task/TaskManager.ts +++ b/packages/@webex/contact-center/src/services/task/TaskManager.ts @@ -42,6 +42,7 @@ export default class TaskManager extends EventEmitter { private taskCollection: Record; private webCallingService: WebCallingService; private webSocketManager: WebSocketManager; + private rtdWebSocketManager: WebSocketManager; // eslint-disable-next-line no-use-before-define private static taskManager: TaskManager; private configFlags?: ConfigFlags; @@ -58,18 +59,51 @@ export default class TaskManager extends EventEmitter { apiAIAssistant: ApiAIAssistant, contact: ReturnType, webCallingService: WebCallingService, - webSocketManager: WebSocketManager + webSocketManager: WebSocketManager, + rtdWebSocketManager: WebSocketManager ) { super(); this.apiAIAssistant = apiAIAssistant; this.contact = contact; this.webCallingService = webCallingService; this.webSocketManager = webSocketManager; + this.rtdWebSocketManager = rtdWebSocketManager; this.taskCollection = {}; this.webRtcEnabled = false; this.registerTaskListeners(); this.registerIncomingCallEvent(); + this.registerRealtimeWSListeners(); + } + + private registerRealtimeWSListeners() { + this.rtdWebSocketManager.on('message', (event: string) => { + try { + const payload = JSON.parse(event); + + const interactionId = payload?.data?.data?.conversationId; + if (!interactionId) return; + + const task = this.taskCollection[interactionId]; + if (!task) { + LoggerProxy.info(`Realtime transcription task not found`, { + module: TASK_MANAGER_FILE, + method: METHODS.REGISTER_REAL_TIME_WS_LISTENERS, + interactionId, + }); + + return; + } + + task.emit(payload.type, payload.data); + } catch (error) { + LoggerProxy.error('Failed to parse RTD WebSocket message', { + module: TASK_MANAGER_FILE, + method: METHODS.REGISTER_TASK_LISTENERS, + error, + }); + } + }); } /** @@ -325,11 +359,6 @@ export default class TaskManager extends EventEmitter { const eventContext = this.prepareEventContext(message); if (!eventContext) return; - if (eventContext.eventType === CC_EVENTS.REAL_TIME_TRANSCRIPTION) { - eventContext.task?.emit(CC_EVENTS.REAL_TIME_TRANSCRIPTION, eventContext.payload); - - return; - } const actions = this.handleTaskLifecycleEvent(eventContext); const {task} = actions; @@ -395,10 +424,7 @@ export default class TaskManager extends EventEmitter { return null; } - const interactionId = - eventType === CC_EVENTS.REAL_TIME_TRANSCRIPTION - ? message.data.data.conversationId - : message.data.interactionId; + const interactionId = message.data.interactionId; const task = this.taskCollection[interactionId]; const wasConsultedTask = Boolean(task?.data?.isConsulted); @@ -733,14 +759,16 @@ export default class TaskManager extends EventEmitter { apiAIAssistant: ApiAIAssistant, contact: ReturnType, webCallingService: WebCallingService, - webSocketManager: WebSocketManager + webSocketManager: WebSocketManager, + rtdWebSocketManager?: WebSocketManager ): TaskManager { if (!TaskManager.taskManager) { TaskManager.taskManager = new TaskManager( apiAIAssistant, contact, webCallingService, - webSocketManager + webSocketManager, + rtdWebSocketManager ); } diff --git a/packages/@webex/contact-center/src/services/task/constants.ts b/packages/@webex/contact-center/src/services/task/constants.ts index 0ceebce3b5d..b3a4f3563d8 100644 --- a/packages/@webex/contact-center/src/services/task/constants.ts +++ b/packages/@webex/contact-center/src/services/task/constants.ts @@ -75,6 +75,7 @@ export const METHODS = { // TaskManager class methods HANDLE_INCOMING_WEB_CALL: 'handleIncomingWebCall', REGISTER_TASK_LISTENERS: 'registerTaskListeners', + REGISTER_REAL_TIME_WS_LISTENERS: 'registerRealtimeWSListeners', REMOVE_TASK_FROM_COLLECTION: 'removeTaskFromCollection', HANDLE_TASK_CLEANUP: 'handleTaskCleanup', GET_TASK: 'getTask', diff --git a/packages/@webex/contact-center/test/unit/spec/cc.ts b/packages/@webex/contact-center/test/unit/spec/cc.ts index 8b788d21b02..8d321aca5ee 100644 --- a/packages/@webex/contact-center/test/unit/spec/cc.ts +++ b/packages/@webex/contact-center/test/unit/spec/cc.ts @@ -314,6 +314,7 @@ describe('webex.cc', () => { clientType: 'WebexCCSDK', allowMultiLogin: false, }, + resource: 'v1/notification/subscribe', }); // TODO: https://jira-eng-gpk2.cisco.com/jira/browse/SPARK-626777 Implement the de-register method and close the listener there @@ -381,6 +382,7 @@ describe('webex.cc', () => { clientType: 'WebexCCSDK', allowMultiLogin: true, }, + resource: 'v1/notification/subscribe', }); expect(configSpy).toHaveBeenCalled(); expect(LoggerProxy.log).toHaveBeenCalledWith('Agent config is fetched successfully', { @@ -460,6 +462,7 @@ describe('webex.cc', () => { clientType: 'WebexCCSDK', allowMultiLogin: false, }, + resource: 'v1/notification/subscribe', }); expect(mockTaskManager.on).toHaveBeenCalledWith( @@ -512,6 +515,7 @@ describe('webex.cc', () => { clientType: 'WebexCCSDK', allowMultiLogin: false, }, + resource: 'v1/notification/subscribe', }); expect(configSpy).toHaveBeenCalled(); @@ -1510,6 +1514,7 @@ describe('webex.cc', () => { describe('unregister', () => { let mockWebSocketManager; + let mockRTDWebSocketManager; let mercuryDisconnectSpy; let deviceUnregisterSpy; @@ -1526,8 +1531,15 @@ describe('webex.cc', () => { off: jest.fn(), on: jest.fn(), }; + mockRTDWebSocketManager = { + isSocketClosed: false, + close: jest.fn(), + off: jest.fn(), + on: jest.fn(), + }; webex.cc.services.webSocketManager = mockWebSocketManager; + webex.cc.services.rtdWebSocketManager = mockRTDWebSocketManager; webex.internal = webex.internal || {}; webex.internal.mercury = { @@ -1561,6 +1573,7 @@ describe('webex.cc', () => { ); expect(mockWebSocketManager.close).toHaveBeenCalledWith(false, 'Unregistering the SDK'); + expect(mockRTDWebSocketManager.close).toHaveBeenCalledWith(false, 'Unregistering the SDK'); expect(webex.cc.agentConfig).toBeNull(); expect(webex.internal.mercury.off).toHaveBeenCalledWith('online'); @@ -1636,6 +1649,7 @@ describe('webex.cc', () => { expect(webex.internal.mercury.off).not.toHaveBeenCalled(); expect(mercuryDisconnectSpy).not.toHaveBeenCalled(); expect(deviceUnregisterSpy).not.toHaveBeenCalled(); + expect(mockRTDWebSocketManager.close).toHaveBeenCalledWith(false, 'Unregistering the SDK'); }); it('should skip internal mercury cleanup when loginVoiceOptions does not include BROWSER', async () => { @@ -1653,6 +1667,7 @@ describe('webex.cc', () => { expect(deviceUnregisterSpy).not.toHaveBeenCalled(); expect(mockWebSocketManager.close).toHaveBeenCalledWith(false, 'Unregistering the SDK'); + expect(mockRTDWebSocketManager.close).toHaveBeenCalledWith(false, 'Unregistering the SDK'); expect(webex.cc.agentConfig).toBeNull(); }); diff --git a/packages/@webex/contact-center/test/unit/spec/services/core/websocket/WebSocketManager.ts b/packages/@webex/contact-center/test/unit/spec/services/core/websocket/WebSocketManager.ts index ef017049bba..45f38eba7ed 100644 --- a/packages/@webex/contact-center/test/unit/spec/services/core/websocket/WebSocketManager.ts +++ b/packages/@webex/contact-center/test/unit/spec/services/core/websocket/WebSocketManager.ts @@ -1,7 +1,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import {WebSocketManager} from '../../../../../../src/services/core/websocket/WebSocketManager'; import {WebexSDK, SubscribeRequest} from '../../../../../../src/types'; -import {SUBSCRIBE_API, WCC_API_GATEWAY} from '../../../../../../src/services/constants'; +import { + RTD_SUBSCRIBE_API, + SUBSCRIBE_API, + WCC_API_GATEWAY, +} from '../../../../../../src/services/constants'; import {WEB_SOCKET_MANAGER_FILE} from '../../../../../../src/constants'; import LoggerProxy from '../../../../../../src/logger-proxy'; @@ -18,10 +22,10 @@ jest.mock('../../../../../../src/logger-proxy', () => ({ class MockWebSocket { static inst: MockWebSocket; - onopen: () => void = () => { }; - onerror: (event: any) => void = () => { }; - onclose: (event: any) => void = () => { }; - onmessage: (msg: any) => void = () => { }; + onopen: () => void = () => {}; + onerror: (event: any) => void = () => {}; + onclose: (event: any) => void = () => {}; + onmessage: (msg: any) => void = () => {}; close = jest.fn(); send = jest.fn(); @@ -37,7 +41,7 @@ class MockWebSocket { class MockCustomEvent extends Event { detail: T; - constructor(event: string, params: { detail: T }) { + constructor(event: string, params: {detail: T}) { super(event); this.detail = params.detail; } @@ -49,7 +53,7 @@ global.CustomEvent = MockCustomEvent as any; class MockMessageEvent extends Event { data: any; - constructor(type: string, eventInitDict: { data: any }) { + constructor(type: string, eventInitDict: {data: any}) { super(type); this.data = eventInitDict.data; } @@ -85,18 +89,18 @@ describe('WebSocketManager', () => { global.WebSocket = MockWebSocket as any; global.Blob = function (content: any[], options: any) { - return { content, options }; + return {content, options}; } as any; global.URL.createObjectURL = function (blob: Blob) { return 'blob:http://localhost:3000/12345'; }; - webSocketManager = new WebSocketManager({ webex: mockWebex }); + webSocketManager = new WebSocketManager({webex: mockWebex}); setTimeout(() => { MockWebSocket.inst.onopen(); - MockWebSocket.inst.onmessage({ data: JSON.stringify({ type: "Welcome" }) }); + MockWebSocket.inst.onmessage({data: JSON.stringify({type: 'Welcome'})}); }, 1); console.log = jest.fn(); @@ -116,7 +120,7 @@ describe('WebSocketManager', () => { (mockWebex.request as jest.Mock).mockResolvedValueOnce(subscribeResponse); - await webSocketManager.initWebSocket({ body: fakeSubscribeRequest }); + await webSocketManager.initWebSocket({body: fakeSubscribeRequest, resource: SUBSCRIBE_API}); expect(mockWebex.request).toHaveBeenCalledWith({ service: WCC_API_GATEWAY, @@ -126,6 +130,28 @@ describe('WebSocketManager', () => { }); }); + it('should connect rtd websocket', async () => { + const subscribeResponse = { + body: { + webSocketUrl: 'wss://fake-url', + }, + }; + + (mockWebex.request as jest.Mock).mockResolvedValueOnce(subscribeResponse); + + await webSocketManager.initWebSocket({ + body: fakeSubscribeRequest, + resource: RTD_SUBSCRIBE_API, + }); + + expect(mockWebex.request).toHaveBeenCalledWith({ + service: WCC_API_GATEWAY, + resource: RTD_SUBSCRIBE_API, + method: 'POST', + body: fakeSubscribeRequest, + }); + }); + it('should close WebSocket connection', async () => { const subscribeResponse = { body: { @@ -135,12 +161,12 @@ describe('WebSocketManager', () => { (mockWebex.request as jest.Mock).mockResolvedValueOnce(subscribeResponse); - await webSocketManager.initWebSocket({ body: fakeSubscribeRequest }); + await webSocketManager.initWebSocket({body: fakeSubscribeRequest, resource: SUBSCRIBE_API}); webSocketManager.close(true, 'Test reason'); expect(MockWebSocket.inst.close).toHaveBeenCalled(); - expect(mockWorker.postMessage).toHaveBeenCalledWith({ type: 'terminate' }); + expect(mockWorker.postMessage).toHaveBeenCalledWith({type: 'terminate'}); }); it('should handle WebSocket keepalive messages', async () => { @@ -152,19 +178,19 @@ describe('WebSocketManager', () => { (mockWebex.request as jest.Mock).mockResolvedValueOnce(subscribeResponse); - await webSocketManager.initWebSocket({ body: fakeSubscribeRequest }); + await webSocketManager.initWebSocket({body: fakeSubscribeRequest, resource: SUBSCRIBE_API}); setTimeout(() => { MockWebSocket.inst.onopen(); - MockWebSocket.inst.onmessage({ data: JSON.stringify({ type: 'keepalive' }) }); + MockWebSocket.inst.onmessage({data: JSON.stringify({type: 'keepalive'})}); mockWorker.onmessage({ data: { - type: 'keepalive' - } + type: 'keepalive', + }, }); }, 1); - expect(MockWebSocket.inst.send).toHaveBeenCalledWith(JSON.stringify({ keepalive: 'true' })); + expect(MockWebSocket.inst.send).toHaveBeenCalledWith(JSON.stringify({keepalive: 'true'})); }); it('should handle WebSocket close due to network issue', async () => { @@ -176,7 +202,7 @@ describe('WebSocketManager', () => { (mockWebex.request as jest.Mock).mockResolvedValueOnce(subscribeResponse); - await webSocketManager.initWebSocket({ body: fakeSubscribeRequest }); + await webSocketManager.initWebSocket({body: fakeSubscribeRequest, resource: SUBSCRIBE_API}); // Mock navigator.onLine to simulate network issue Object.defineProperty(global, 'navigator', { @@ -199,10 +225,10 @@ describe('WebSocketManager', () => { // Wait for the close event to be handled await new Promise((resolve) => setTimeout(resolve, 10)); - expect(mockWorker.postMessage).toHaveBeenCalledWith({ type: 'terminate' }); + expect(mockWorker.postMessage).toHaveBeenCalledWith({type: 'terminate'}); expect(LoggerProxy.error).toHaveBeenCalledWith( '[WebSocketStatus] | event=webSocketClose | WebSocket connection closed REASON: network issue', - { module: WEB_SOCKET_MANAGER_FILE, method: 'webSocketOnCloseHandler' } + {module: WEB_SOCKET_MANAGER_FILE, method: 'webSocketOnCloseHandler'} ); // Restore navigator.onLine to true @@ -223,14 +249,14 @@ describe('WebSocketManager', () => { (mockWebex.request as jest.Mock).mockResolvedValueOnce(subscribeResponse); - await webSocketManager.initWebSocket({ body: fakeSubscribeRequest }); + await webSocketManager.initWebSocket({body: fakeSubscribeRequest, resource: SUBSCRIBE_API}); const errorEvent = new Event('error'); MockWebSocket.inst.onerror(errorEvent); expect(LoggerProxy.error).toHaveBeenCalledWith( '[WebSocketStatus] | event=socketConnectionFailed | WebSocket connection failed [object Event]', - { module: WEB_SOCKET_MANAGER_FILE, method: 'connect' } + {module: WEB_SOCKET_MANAGER_FILE, method: 'connect'} ); }); @@ -243,17 +269,17 @@ describe('WebSocketManager', () => { (mockWebex.request as jest.Mock).mockResolvedValueOnce(subscribeResponse); - await webSocketManager.initWebSocket({ body: fakeSubscribeRequest }); + await webSocketManager.initWebSocket({body: fakeSubscribeRequest, resource: SUBSCRIBE_API}); const messageEvent = new MessageEvent('message', { - data: JSON.stringify({ type: 'AGENT_MULTI_LOGIN' }), + data: JSON.stringify({type: 'AGENT_MULTI_LOGIN'}), }); MockWebSocket.inst.onmessage(messageEvent); expect(MockWebSocket.inst.close).toHaveBeenCalled(); expect(LoggerProxy.error).toHaveBeenCalledWith( '[WebSocketStatus] | event=agentMultiLogin | WebSocket connection closed by agent multiLogin', - { module: WEB_SOCKET_MANAGER_FILE, method: 'connect' } + {module: WEB_SOCKET_MANAGER_FILE, method: 'connect'} ); }); @@ -266,10 +292,10 @@ describe('WebSocketManager', () => { (mockWebex.request as jest.Mock).mockResolvedValueOnce(subscribeResponse); - await webSocketManager.initWebSocket({ body: fakeSubscribeRequest }); + await webSocketManager.initWebSocket({body: fakeSubscribeRequest, resource: SUBSCRIBE_API}); const messageEvent = new MessageEvent('message', { - data: JSON.stringify({ type: 'Welcome', data: { someData: 'data' } }), + data: JSON.stringify({type: 'Welcome', data: {someData: 'data'}}), }); MockWebSocket.inst.onmessage(messageEvent); @@ -285,7 +311,7 @@ describe('WebSocketManager', () => { (mockWebex.request as jest.Mock).mockResolvedValueOnce(subscribeResponse); - await webSocketManager.initWebSocket({ body: fakeSubscribeRequest }); + await webSocketManager.initWebSocket({body: fakeSubscribeRequest, resource: SUBSCRIBE_API}); webSocketManager['forceCloseWebSocketOnTimeout'] = true; @@ -304,10 +330,10 @@ describe('WebSocketManager', () => { // Wait for the close event to be handled await new Promise((resolve) => setTimeout(resolve, 10)); - expect(mockWorker.postMessage).toHaveBeenCalledWith({ type: 'terminate' }); + expect(mockWorker.postMessage).toHaveBeenCalledWith({type: 'terminate'}); expect(LoggerProxy.error).toHaveBeenCalledWith( '[WebSocketStatus] | event=webSocketClose | WebSocket connection closed REASON: WebSocket auto close timed out. Forcefully closed websocket.', - { module: WEB_SOCKET_MANAGER_FILE, method: 'webSocketOnCloseHandler' } + {module: WEB_SOCKET_MANAGER_FILE, method: 'webSocketOnCloseHandler'} ); }); @@ -320,7 +346,7 @@ describe('WebSocketManager', () => { (mockWebex.request as jest.Mock).mockResolvedValueOnce(subscribeResponse); - await webSocketManager.initWebSocket({ body: fakeSubscribeRequest }); + await webSocketManager.initWebSocket({body: fakeSubscribeRequest, resource: SUBSCRIBE_API}); webSocketManager.shouldReconnect = false; // Simulate the WebSocket close event setTimeout(() => { @@ -331,13 +357,13 @@ describe('WebSocketManager', () => { target: MockWebSocket.inst, }); }, 1); - + await new Promise((resolve) => setTimeout(resolve, 10)); - expect(mockWorker.postMessage).toHaveBeenCalledWith({ type: 'terminate' }); + expect(mockWorker.postMessage).toHaveBeenCalledWith({type: 'terminate'}); expect(LoggerProxy.error).not.toHaveBeenCalledWith( '[WebSocketStatus] | event=webSocketClose | WebSocket connection closed REASON: no reconnect', - { module: WEB_SOCKET_MANAGER_FILE, method: 'webSocketOnCloseHandler' } + {module: WEB_SOCKET_MANAGER_FILE, method: 'webSocketOnCloseHandler'} ); }); @@ -350,7 +376,7 @@ describe('WebSocketManager', () => { (mockWebex.request as jest.Mock).mockResolvedValueOnce(subscribeResponse); - await webSocketManager.initWebSocket({ body: fakeSubscribeRequest }); + await webSocketManager.initWebSocket({body: fakeSubscribeRequest, resource: SUBSCRIBE_API}); // Simulate the WebSocket close event setTimeout(() => { @@ -365,10 +391,10 @@ describe('WebSocketManager', () => { // Wait for the close event to be handled await new Promise((resolve) => setTimeout(resolve, 10)); - expect(mockWorker.postMessage).toHaveBeenCalledWith({ type: 'terminate' }); + expect(mockWorker.postMessage).toHaveBeenCalledWith({type: 'terminate'}); expect(LoggerProxy.error).not.toHaveBeenCalledWith( '[WebSocketStatus] | event=webSocketClose | WebSocket connection closed REASON: clean close', - { module: WEB_SOCKET_MANAGER_FILE, method: 'webSocketOnCloseHandler' } + {module: WEB_SOCKET_MANAGER_FILE, method: 'webSocketOnCloseHandler'} ); }); -}); \ No newline at end of file +}); diff --git a/packages/@webex/contact-center/test/unit/spec/services/core/websocket/connection-service.ts b/packages/@webex/contact-center/test/unit/spec/services/core/websocket/connection-service.ts index b26e516e456..e063ac3d976 100644 --- a/packages/@webex/contact-center/test/unit/spec/services/core/websocket/connection-service.ts +++ b/packages/@webex/contact-center/test/unit/spec/services/core/websocket/connection-service.ts @@ -3,7 +3,7 @@ import {WebSocketManager} from '../../../../../../src/services/core/websocket/We import {SubscribeRequest} from '../../../../../../src/types'; import LoggerProxy from '../../../../../../src/logger-proxy'; import {CONNECTIVITY_CHECK_INTERVAL} from '../../../../../../src/services/core/constants'; -import { CONNECTION_SERVICE_FILE } from '../../../../../../src/constants'; +import {CONNECTION_SERVICE_FILE} from '../../../../../../src/constants'; jest.mock('../../../../../../src/services/core/websocket/WebSocketManager'); jest.mock('../../../../../../src/logger-proxy', () => ({ @@ -109,7 +109,10 @@ describe('ConnectionService', () => { 'event=socketConnectionRetry | Trying to reconnect to websocket', {module: CONNECTION_SERVICE_FILE, method: 'handleSocketClose'} ); - expect(mockWebSocketManager.initWebSocket).toHaveBeenCalledWith({body: mockSubscribeRequest}); + expect(mockWebSocketManager.initWebSocket).toHaveBeenCalledWith({ + body: mockSubscribeRequest, + resource: 'v1/notification/subscribe', + }); }); describe('ConnectionService onPing', () => { diff --git a/packages/@webex/contact-center/test/unit/spec/services/task/TaskManager.ts b/packages/@webex/contact-center/test/unit/spec/services/task/TaskManager.ts index 0ae07ad64e0..a05bda9b4f1 100644 --- a/packages/@webex/contact-center/test/unit/spec/services/task/TaskManager.ts +++ b/packages/@webex/contact-center/test/unit/spec/services/task/TaskManager.ts @@ -19,6 +19,7 @@ describe('TaskManager', () => { let mockCall; let mockApiAIAssistant; let webSocketManagerMock; + let rtdWebSocketManagerMock; let onSpy; let offSpy; let taskManager; @@ -159,6 +160,7 @@ describe('TaskManager', () => { beforeEach(() => { contactMock = contact; webSocketManagerMock = new EventEmitter(); + rtdWebSocketManagerMock = new EventEmitter(); webex = { logger: { @@ -196,7 +198,8 @@ describe('TaskManager', () => { mockApiAIAssistant as any, contactMock, webCallingService, - webSocketManagerMock as any + webSocketManagerMock as any, + rtdWebSocketManagerMock as any ); taskManager.taskCollection[taskId] = createMockTask(taskDataMock); (taskManager as any).setupTaskListeners?.(taskManager.taskCollection[taskId]); @@ -222,6 +225,7 @@ describe('TaskManager', () => { expect(taskManager).toBeInstanceOf(TaskManager); expect(webCallingService.listenerCount(LINE_EVENTS.INCOMING_CALL)).toBe(1); expect(webSocketManagerMock.listenerCount('message')).toBe(1); + expect(rtdWebSocketManagerMock.listenerCount('message')).toBe(1); expect(onSpy).toHaveBeenCalledWith(LINE_EVENTS.INCOMING_CALL, incomingCallCb); incomingCallCb(mockCall); @@ -313,7 +317,7 @@ describe('TaskManager', () => { expect(mockApiAIAssistant.sendEvent).not.toHaveBeenCalled(); }); - it('should emit REAL_TIME_TRANSCRIPTION when eventType is in top-level payload', () => { + it('should emit REAL_TIME_TRANSCRIPTION from RTD websocket payload', () => { const task = taskManager.getTask(taskId); const taskEmitSpy = jest.spyOn(task, 'emit'); const realtimePayload = { @@ -342,7 +346,7 @@ describe('TaskManager', () => { type: 'REAL_TIME_TRANSCRIPTION', }; - webSocketManagerMock.emit('message', JSON.stringify(realtimePayload)); + rtdWebSocketManagerMock.emit('message', JSON.stringify(realtimePayload)); expect(taskEmitSpy).toHaveBeenCalledWith( CC_EVENTS.REAL_TIME_TRANSCRIPTION, @@ -350,6 +354,39 @@ describe('TaskManager', () => { ); }); + it('should ignore RTD transcript events when task is not found', () => { + const realtimePayload = { + data: { + data: { + content: 'Thank you. Okay.', + conversationId: 'missing-task-id', + isFinal: true, + languageCode: 'en-US', + messageId: '1', + orgId: 'org-id', + publishTimestamp: 1773807297475, + role: 'AGENT', + trackingId: 'tracking-id', + utteranceId: 'utterance-id', + }, + notifDetails: { + actionEvent: 'REAL_TIME_TRANSCRIPTION', + }, + notifType: 'REAL_TIME_TRANSCRIPTION', + orgId: 'org-id', + }, + orgId: 'org-id', + trackingId: 'notifs_tracking-id', + type: 'REAL_TIME_TRANSCRIPTION', + }; + + const existingTaskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit'); + + rtdWebSocketManagerMock.emit('message', JSON.stringify(realtimePayload)); + + expect(existingTaskEmitSpy).not.toHaveBeenCalled(); + }); + it('should not re-emit agent related events', () => { const dummyPayload = { data: {