From 75d17aeffa25173a15c647f4584f886fc2131123 Mon Sep 17 00:00:00 2001 From: Priya Date: Fri, 13 Mar 2026 01:21:07 +0530 Subject: [PATCH 01/10] feat(ccsdk): real time transcript implementation --- packages/@webex/contact-center/src/cc.ts | 20 ++ packages/@webex/contact-center/src/index.ts | 6 + .../contact-center/src/metrics/constants.ts | 7 + .../src/services/ApiAiAssistant.ts | 201 ++++++++++++++++++ .../src/services/config/Util.ts | 54 +++++ .../src/services/config/constants.ts | 11 + .../src/services/config/index.ts | 47 ++++ .../src/services/config/types.ts | 67 ++++++ .../src/services/core/WebexRequest.ts | 14 ++ 9 files changed, 427 insertions(+) create mode 100644 packages/@webex/contact-center/src/services/ApiAiAssistant.ts diff --git a/packages/@webex/contact-center/src/cc.ts b/packages/@webex/contact-center/src/cc.ts index e9ea6c92674..e5b9a21601d 100644 --- a/packages/@webex/contact-center/src/cc.ts +++ b/packages/@webex/contact-center/src/cc.ts @@ -63,6 +63,7 @@ import {Failure} from './services/core/GlobalTypes'; import {EntryPoint} from './services/EntryPoint'; import {AddressBook} from './services/AddressBook'; import {Queue} from './services/Queue'; +import {ApiAIAssistant} from './services/ApiAiAssistant'; import type { EntryPointListResponse, EntryPointSearchParams, @@ -320,6 +321,13 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter */ public queue: Queue; + /** + * API instance for AI Assistant operations such as transcript controls. + * @type {ApiAIAssistant} + * @public + */ + public apiAIAssistant: ApiAIAssistant; + /** * Logger utility for Contact Center plugin * Provides consistent logging across the plugin @@ -371,6 +379,7 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter this.entryPoint = new EntryPoint(this.$webex); this.addressBook = new AddressBook(this.$webex, () => this.agentConfig?.addressBookId); this.queue = new Queue(this.$webex); + this.apiAIAssistant = new ApiAIAssistant(this.$webex, () => this.agentConfig); // Initialize logger LoggerProxy.initialize(this.$webex.logger); @@ -1084,6 +1093,17 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter return; } + if (eventData.type === CC_EVENTS.REAL_TIME_TRANSCRIPTION) { + const interactionId = + eventData?.data?.data?.interactionId || eventData?.data?.data?.conversationId; + if (interactionId) { + const task = this.taskManager.getTask(interactionId); + if (task) { + task.emit(CC_EVENTS.REAL_TIME_TRANSCRIPTION, eventData.data); + } + } + } + LoggerProxy.log(`Received event: ${eventData?.data?.type ?? eventData.type}`, { module: CC_FILE, method: METHODS.HANDLE_WEBSOCKET_MESSAGE, diff --git a/packages/@webex/contact-center/src/index.ts b/packages/@webex/contact-center/src/index.ts index be7517b2b19..bfef235e46c 100644 --- a/packages/@webex/contact-center/src/index.ts +++ b/packages/@webex/contact-center/src/index.ts @@ -27,6 +27,7 @@ export {default as Task} from './services/task/Task'; // API exports (AddressBook is public, EntryPoint and Queue are accessed via cc wrappers) export {default as AddressBook} from './services/AddressBook'; +export {default as ApiAIAssistant} from './services/ApiAiAssistant'; /** EntryPoint API types */ export type { @@ -40,6 +41,11 @@ export type { ContactServiceQueueSearchParams, ContactServiceQueue, } from './types'; +export type { + HistoricTranscriptsResponse, + TranscriptMessage, + TranscriptAction, +} from './services/ApiAiAssistant'; // Enums /** diff --git a/packages/@webex/contact-center/src/metrics/constants.ts b/packages/@webex/contact-center/src/metrics/constants.ts index af9854ae2eb..853ada894e6 100644 --- a/packages/@webex/contact-center/src/metrics/constants.ts +++ b/packages/@webex/contact-center/src/metrics/constants.ts @@ -162,6 +162,13 @@ export const METRIC_EVENT_NAMES = { // Outdial ANI Entries API Events OUTDIAL_ANI_EP_FETCH_SUCCESS: 'Outdial ANI Entries Fetch Success', OUTDIAL_ANI_EP_FETCH_FAILED: 'Outdial ANI Entries Fetch Failed', + + // AI Assistant transcript events + AI_ASSISTANT_SEND_TRANSCRIPT_EVENT_SUCCESS: 'AI Assistant Send Transcript Event Success', + AI_ASSISTANT_SEND_TRANSCRIPT_EVENT_FAILED: 'AI Assistant Send Transcript Event Failed', + AI_ASSISTANT_FETCH_HISTORIC_TRANSCRIPTS_SUCCESS: + 'AI Assistant Fetch Historic Transcripts Success', + AI_ASSISTANT_FETCH_HISTORIC_TRANSCRIPTS_FAILED: 'AI Assistant Fetch Historic Transcripts Failed', } as const; /** diff --git a/packages/@webex/contact-center/src/services/ApiAiAssistant.ts b/packages/@webex/contact-center/src/services/ApiAiAssistant.ts new file mode 100644 index 00000000000..26c7b15ed36 --- /dev/null +++ b/packages/@webex/contact-center/src/services/ApiAiAssistant.ts @@ -0,0 +1,201 @@ +import LoggerProxy from '../logger-proxy'; +import MetricsManager from '../metrics/MetricsManager'; +import {METRIC_EVENT_NAMES} from '../metrics/constants'; +import {CC_FILE} from '../constants'; +import {HTTP_METHODS, WebexSDK, IHttpResponse} from '../types'; +import {getErrorDetails} from './core/Utils'; +import {Profile} from './config/types'; + +const METHODS = { + SEND_TRANSCRIPT_EVENT: 'sendTranscriptEvent', + FETCH_HISTORIC_TRANSCRIPTS: 'fetchHistoricTranscripts', +} as const; + +export type TranscriptAction = 'START' | 'STOP'; + +export type TranscriptMessage = { + role: string; + content: string; + messageId: string; + publishTimestamp: number; +}; + +export type HistoricTranscriptsResponse = { + orgId: string; + agentId: string; + conversationId: string | null; + interactionId: string; + source: string; + data: TranscriptMessage[]; +}; + +/** + * ApiAIAssistant provides AI Assistant APIs for transcript controls. + * @public + */ +export class ApiAIAssistant { + private webex: WebexSDK; + private metricsManager: MetricsManager; + private getAgentConfig: () => Profile | undefined; + + constructor(webex: WebexSDK, getAgentConfig: () => Profile | undefined) { + this.webex = webex; + this.metricsManager = MetricsManager.getInstance({webex}); + this.getAgentConfig = getAgentConfig; + } + + private getBaseUrl(): string { + const profile = this.getAgentConfig(); + const aiAssistantBaseUrl = profile?.aiFeature?.aiAssistantBaseUrl; + if (!aiAssistantBaseUrl) { + throw new Error('AI_ASSISTANT_BASE_URL_NOT_AVAILABLE'); + } + + return aiAssistantBaseUrl; + } + + private getRequiredAgentContext(): {agentId: string; orgId: string} { + const profile = this.getAgentConfig(); + const agentId = profile?.agentId; + const orgId = this.webex.credentials.getOrgId(); + + if (!agentId || !orgId) { + throw new Error('AGENT_CONTEXT_NOT_AVAILABLE'); + } + + return {agentId, orgId}; + } + + /** + * Sends transcript start/stop event for an interaction. + * @param interactionId - interaction/conversation identifier + * @param action - START or STOP + */ + public async sendTranscriptEvent( + interactionId: string, + action: TranscriptAction + ): Promise> { + LoggerProxy.info('Sending transcript event', { + module: CC_FILE, + method: METHODS.SEND_TRANSCRIPT_EVENT, + interactionId, + data: {action}, + }); + this.metricsManager.timeEvent([ + METRIC_EVENT_NAMES.AI_ASSISTANT_SEND_TRANSCRIPT_EVENT_SUCCESS, + METRIC_EVENT_NAMES.AI_ASSISTANT_SEND_TRANSCRIPT_EVENT_FAILED, + ]); + + try { + const {agentId, orgId} = this.getRequiredAgentContext(); + const baseUrl = this.getBaseUrl(); + const response = (await this.webex.request({ + uri: `${baseUrl}/event`, + method: HTTP_METHODS.POST, + addAuthHeader: true, + body: { + agentId, + orgId, + eventType: 'CUSTOM_EVENT', + eventName: 'GET_TRANSCRIPTS', + eventDetails: { + data: { + interactionId, + action, + actionTimeStamp: String(Date.now()), + }, + }, + }, + })) as IHttpResponse; + + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.AI_ASSISTANT_SEND_TRANSCRIPT_EVENT_SUCCESS, + {agentId, orgId, interactionId, action}, + ['operational'] + ); + + return response?.body || {}; + } catch (error) { + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.AI_ASSISTANT_SEND_TRANSCRIPT_EVENT_FAILED, + { + interactionId, + action, + error: error instanceof Error ? error.message : String(error), + }, + ['operational'] + ); + const {error: detailedError} = getErrorDetails(error, METHODS.SEND_TRANSCRIPT_EVENT, CC_FILE); + throw detailedError; + } + } + + /** + * Fetches historic transcripts for an interaction. + * This API is allowed only when real-time transcription feature is enabled. + * + * @param interactionId - interaction/conversation identifier + */ + public async fetchHistoricTranscripts( + interactionId: string + ): Promise { + LoggerProxy.info('Fetching historic transcripts', { + module: CC_FILE, + method: METHODS.FETCH_HISTORIC_TRANSCRIPTS, + interactionId, + }); + this.metricsManager.timeEvent([ + METRIC_EVENT_NAMES.AI_ASSISTANT_FETCH_HISTORIC_TRANSCRIPTS_SUCCESS, + METRIC_EVENT_NAMES.AI_ASSISTANT_FETCH_HISTORIC_TRANSCRIPTS_FAILED, + ]); + + try { + const profile = this.getAgentConfig(); + const featureEnabled = Boolean( + profile?.aiFeature?.realTimeTranscriptionEnabled ?? + profile?.['ai-feature']?.realTimeTranscriptionEnabled + ); + if (!featureEnabled) { + throw new Error('REAL_TIME_TRANSCRIPTION_NOT_ENABLED'); + } + + const {agentId, orgId} = this.getRequiredAgentContext(); + const baseUrl = this.getBaseUrl(); + const response = (await this.webex.request({ + uri: `${baseUrl}/transcripts/list`, + method: HTTP_METHODS.POST, + addAuthHeader: true, + body: { + agentId, + orgId, + interactionId, + }, + })) as IHttpResponse; + + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.AI_ASSISTANT_FETCH_HISTORIC_TRANSCRIPTS_SUCCESS, + {agentId, orgId, interactionId}, + ['operational'] + ); + + return response.body as HistoricTranscriptsResponse; + } catch (error) { + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.AI_ASSISTANT_FETCH_HISTORIC_TRANSCRIPTS_FAILED, + { + interactionId, + error: error instanceof Error ? error.message : String(error), + }, + ['operational'] + ); + const {error: detailedError} = getErrorDetails( + error, + METHODS.FETCH_HISTORIC_TRANSCRIPTS, + CC_FILE + ); + throw detailedError; + } + } +} + +export default ApiAIAssistant; diff --git a/packages/@webex/contact-center/src/services/config/Util.ts b/packages/@webex/contact-center/src/services/config/Util.ts index 43b072637c5..ad0ea6c44b0 100644 --- a/packages/@webex/contact-center/src/services/config/Util.ts +++ b/packages/@webex/contact-center/src/services/config/Util.ts @@ -1,6 +1,7 @@ import {LOST_CONNECTION_RECOVERY_TIMEOUT} from '../core/constants'; import { AgentResponse, + ListAIFeatureResourcesResponse, AuxCode, AuxCodeType, DesktopProfileResponse, @@ -61,6 +62,44 @@ const getDefaultAgentDN = (agentDNValidation: string) => { return agentDNValidation === 'PROVISIONED_VALUE'; }; +/** + * Resolve AI Assistant base URL from discovered WCC API gateway URL. + * The AI Assistant service is not in U2C catalog yet, so we derive env manually. + * + * @param {string} wccApiGatewayUrl + * @returns {string} + */ +const getAIAssistantBaseUrl = (wccApiGatewayUrl: string) => { + if (!wccApiGatewayUrl) { + return ''; + } + + let hostname = ''; + try { + hostname = new URL(wccApiGatewayUrl).hostname.toLowerCase(); + } catch (_error) { + hostname = wccApiGatewayUrl.toLowerCase(); + } + + const envMap: Record = { + 'api.intgus1.ciscoccservice.com': 'intgus1', + 'api.qaus1.ciscoccservice.com': 'qaus1', + 'api.wxcc-us1.cisco.com': 'produs1', + 'api.wxcc-eu1.cisco.com': 'prodeu1', + 'api.wxcc-eu2.cisco.com': 'prodeu2', + 'api.wxcc-anz1.cisco.com': 'prodanz1', + 'api.wxcc-ca1.cisco.com': 'prodca1', + 'api.wxcc-jp1.cisco.com': 'prodjp1', + 'api.wxcc-sg1.cisco.com': 'prodsg1', + 'api.wxcc-in1.cisco.com': 'prodin1', + 'api.loadus1.cisco.com': 'loadus1', + }; + + const resolvedEnv = envMap[hostname]; + + return resolvedEnv ? `https://api-ai-assistant.${resolvedEnv}.ciscoccservice.com` : ''; +}; + /** * Get the filtered dialplan entries * @param {Array} dialPlanData @@ -140,6 +179,8 @@ function parseAgentConfigs(profileData: { dialPlanData: DialPlanEntity[]; urlMapping: URLMapping[]; multimediaProfileId: string; + aiFeatureResources: ListAIFeatureResourcesResponse; + wccApiGatewayUrl: string; }): Profile { const { userData, @@ -151,6 +192,8 @@ function parseAgentConfigs(profileData: { agentProfileData, dialPlanData, urlMapping, + aiFeatureResources, + wccApiGatewayUrl, } = profileData; const tenantDataTimeout = tenantData.timeoutDesktopInactivityEnabled @@ -180,6 +223,10 @@ function parseAgentConfigs(profileData: { }); // pushing available state to idle codes const defaultWrapUpData = getDefaultWrapUpCode(wrapupCodes); + const realTimeTranscriptionEnabled = Boolean( + aiFeatureResources?.data?.[0]?.realtimeTranscripts?.enable + ); + const aiAssistantBaseUrl = getAIAssistantBaseUrl(wccApiGatewayUrl); const finalData = { teams: teamData, @@ -253,6 +300,13 @@ function parseAgentConfigs(profileData: { webexConfig: getWebexConfig(agentProfileData), lostConnectionRecoveryTimeout: tenantData.lostConnectionRecoveryTimeout || LOST_CONNECTION_RECOVERY_TIMEOUT, + aiFeature: { + realTimeTranscriptionEnabled, + aiAssistantBaseUrl, + }, + 'ai-feature': { + realTimeTranscriptionEnabled, + }, }; return finalData; diff --git a/packages/@webex/contact-center/src/services/config/constants.ts b/packages/@webex/contact-center/src/services/config/constants.ts index b3376c9be1d..309c3d261db 100644 --- a/packages/@webex/contact-center/src/services/config/constants.ts +++ b/packages/@webex/contact-center/src/services/config/constants.ts @@ -67,6 +67,7 @@ export const METHODS = { GET_TENANT_DATA: 'getTenantData', GET_URL_MAPPING: 'getURLMapping', GET_DIAL_PLAN_DATA: 'getDialPlanData', + GET_AI_FEATURE_RESOURCES: 'getAIFeatureResources', GET_QUEUES: 'getQueues', // Util methods @@ -233,6 +234,16 @@ export const endPointMap = { * @ignore */ dialPlan: (orgId: string) => `organization/${orgId}/dial-plan?agentView=true`, + /** + * Gets the endpoint for listing AI feature resources. + * @param orgId - Organization ID. + * @returns The endpoint URL string. + * @public + * @example + * const url = endPointMap.aiFeatureResources('org123'); + * @ignore + */ + aiFeatureResources: (orgId: string) => `organization/${orgId}/v2/ai-feature?page=0&pageSize=100`, /** * Gets the endpoint for the queue list with custom query parameters. diff --git a/packages/@webex/contact-center/src/services/config/index.ts b/packages/@webex/contact-center/src/services/config/index.ts index 163ee50a058..f714d234aa3 100644 --- a/packages/@webex/contact-center/src/services/config/index.ts +++ b/packages/@webex/contact-center/src/services/config/index.ts @@ -8,6 +8,7 @@ import LoggerProxy from '../../logger-proxy'; import { DesktopProfileResponse, ListAuxCodesResponse, + ListAIFeatureResourcesResponse, AgentResponse, TenantData, OrgInfo, @@ -61,6 +62,7 @@ export default class AgentConfigService { const orgSettingsPromise = this.getOrganizationSetting(orgId); const tenantDataPromise = this.getTenantData(orgId); const urlMappingPromise = this.getURLMapping(orgId); + const aiFeatureResourcesPromise = this.getAIFeatureResources(orgId); const auxCodesPromise = this.getAllAuxCodes( orgId, DEFAULT_PAGE_SIZE, @@ -94,6 +96,7 @@ export default class AgentConfigService { orgSettingsData, tenantData, urlMappingData, + aiFeatureResources, auxCodesData, ] = await Promise.all([ agentProfilePromise, @@ -104,8 +107,10 @@ export default class AgentConfigService { orgSettingsPromise, tenantDataPromise, urlMappingPromise, + aiFeatureResourcesPromise, auxCodesPromise, ]); + const wccApiGatewayUrl = this.webexReq.getServiceUrl(WCC_API_GATEWAY); const multimediaProfileId = userConfigData.multimediaProfileId || @@ -128,6 +133,8 @@ export default class AgentConfigService { dialPlanData: userDialPlanData, urlMapping: urlMappingData, multimediaProfileId, + aiFeatureResources, + wccApiGatewayUrl, }); LoggerProxy.info('Parsing completed for agent-config', { @@ -651,6 +658,46 @@ export default class AgentConfigService { } } + /** + * Fetches AI feature resources for the organization. + * @ignore + * @param {string} orgId - organization ID for which AI feature resources are to be fetched. + * @returns {Promise} - AI feature resources response. + * @throws {Error} - Throws an error if the API call fails or if the response status is not 200. + * @private + */ + public async getAIFeatureResources(orgId: string): Promise { + LoggerProxy.info('Fetching AI feature resources', { + module: CONFIG_FILE_NAME, + method: METHODS.GET_AI_FEATURE_RESOURCES, + }); + try { + const resource = endPointMap.aiFeatureResources(orgId); + const response = await this.webexReq.request({ + service: WCC_API_GATEWAY, + resource, + method: HTTP_METHODS.GET, + }); + + if (response.statusCode !== 200) { + throw new Error(`API call failed with ${response.statusCode}`); + } + + LoggerProxy.log('getAIFeatureResources api success.', { + module: CONFIG_FILE_NAME, + method: METHODS.GET_AI_FEATURE_RESOURCES, + }); + + return Promise.resolve(response.body); + } catch (error) { + LoggerProxy.error(`getAIFeatureResources API call failed with ${error}`, { + module: CONFIG_FILE_NAME, + method: METHODS.GET_AI_FEATURE_RESOURCES, + }); + throw error; + } + } + /** * Fetches the dial plan data for the given orgId. * @ignore diff --git a/packages/@webex/contact-center/src/services/config/types.ts b/packages/@webex/contact-center/src/services/config/types.ts index 16133dc9188..8e240a4666f 100644 --- a/packages/@webex/contact-center/src/services/config/types.ts +++ b/packages/@webex/contact-center/src/services/config/types.ts @@ -119,6 +119,8 @@ export const CC_TASK_EVENTS = { AGENT_CONTACT_UNASSIGNED: 'AgentContactUnassigned', /** Event emitted when inviting agent fails */ AGENT_INVITE_FAILED: 'AgentInviteFailed', + /** Event emitted when a real-time transcript chunk is received */ + REAL_TIME_TRANSCRIPTION: 'REAL_TIME_TRANSCRIPTION', } as const; /** @@ -1153,6 +1155,71 @@ export type Profile = { lastStateChangeTimestamp?: number; /** Timestamp of last idle code change */ lastIdleCodeChangeTimestamp?: number; + /** AI feature flags resolved from organization config */ + aiFeature?: { + /** Whether real-time transcription is enabled for this agent/org */ + realTimeTranscriptionEnabled: boolean; + /** Base URL for AI Assistant APIs derived from WCC gateway environment */ + aiAssistantBaseUrl?: string; + }; + /** Legacy key preserved for compatibility with dashboard/client integrations */ + 'ai-feature'?: { + /** Whether real-time transcription is enabled for this agent/org */ + realTimeTranscriptionEnabled: boolean; + }; +}; + +/** + * AI feature resource row returned by /v2/ai-feature API. + * @public + */ +export type AIFeatureResource = { + id: string; + realtimeTranscripts?: { + enable?: boolean; + agentInclusionType?: string; + }; + suggestedResponses?: { + enable?: boolean; + }; + generatedSummaries?: { + callDropSummariesEnabled?: boolean; + virtualAgentTransferSummariesEnabled?: boolean; + consultTransferSummariesEnabled?: boolean; + wrapUpSummariesEnabled?: boolean; + queuesInclusionType?: string; + }; + agentWellbeing?: { + enable?: boolean; + agentInclusionType?: string; + wellnessBreakReminders?: string; + }; + autoCSAT?: { + enable?: boolean; + queuesInclusionType?: string; + surveyDataSource?: string; + }; + links?: string[]; + createdTime?: number; + lastUpdatedTime?: number; +}; + +/** + * Response type for list AI feature resources API. + * @public + */ +export type ListAIFeatureResourcesResponse = { + meta?: { + orgid?: string; + page?: number; + pageSize?: number; + totalPages?: number; + totalRecords?: number; + links?: { + self?: string; + }; + }; + data: AIFeatureResource[]; }; /** diff --git a/packages/@webex/contact-center/src/services/core/WebexRequest.ts b/packages/@webex/contact-center/src/services/core/WebexRequest.ts index a66176960d3..ea94c614914 100644 --- a/packages/@webex/contact-center/src/services/core/WebexRequest.ts +++ b/packages/@webex/contact-center/src/services/core/WebexRequest.ts @@ -45,6 +45,20 @@ class WebexRequest { }); } + /** + * Returns resolved service URL from Webex service catalog. + * + * @param service - service key from catalog + * @returns resolved service URL or empty string + */ + public getServiceUrl(service: string): string { + try { + return this.webex.internal.services.get(service) || ''; + } catch (_error) { + return ''; + } + } + /** * This is used for uploading the logs to backend/mats. * From 1f4059d521b479f20c3cd4a0e265a2783dcf7db8 Mon Sep 17 00:00:00 2001 From: Priya Date: Wed, 18 Mar 2026 01:14:20 +0530 Subject: [PATCH 02/10] feat(contact-center): fixed the approach and add missing implementations --- docs/samples/contact-center/app.js | 30 ++ docs/samples/contact-center/index.html | 10 + packages/@webex/contact-center/src/cc.ts | 17 +- .../@webex/contact-center/src/constants.ts | 2 + packages/@webex/contact-center/src/index.ts | 5 - .../contact-center/src/metrics/constants.ts | 6 +- .../src/services/ApiAiAssistant.ts | 145 ++++---- .../src/services/config/Util.ts | 61 +--- .../src/services/config/constants.ts | 8 +- .../src/services/config/index.ts | 29 +- .../src/services/config/types.ts | 118 +++---- .../src/services/task/TaskManager.ts | 54 ++- .../contact-center/src/services/task/types.ts | 1 + packages/@webex/contact-center/src/types.ts | 28 ++ .../contact-center/test/unit/spec/cc.ts | 12 + .../test/unit/spec/services/ApiAiAssistant.ts | 115 ++++++ .../test/unit/spec/services/config/index.ts | 11 + .../unit/spec/services/task/TaskManager.ts | 329 +++++++++++------- 18 files changed, 624 insertions(+), 357 deletions(-) create mode 100644 packages/@webex/contact-center/test/unit/spec/services/ApiAiAssistant.ts diff --git a/docs/samples/contact-center/app.js b/docs/samples/contact-center/app.js index 721fd927f08..cb79f5e63d2 100644 --- a/docs/samples/contact-center/app.js +++ b/docs/samples/contact-center/app.js @@ -89,8 +89,34 @@ const applyupdateAgentProfileBtn = document.querySelector('#applyupdateAgentProf const autoWrapupTimerElm = document.getElementById('autoWrapupTimer'); const timerValueElm = autoWrapupTimerElm.querySelector('.timer-value'); const outdialAniSelectElm = document.querySelector('#outdialAniSelect'); +const realtimeTranscriptsElm = document.querySelector('#realtime-transcripts-content'); +const clearTranscriptsButton = document.querySelector('#clear-transcripts'); deregisterBtn.style.backgroundColor = 'red'; +const transcriptLines = []; +const MAX_TRANSCRIPT_LINES = 200; + +function appendRealtimeTranscript(payload) { + const transcriptContent = payload?.data?.data?.content || payload?.data?.content; + if (!transcriptContent || typeof transcriptContent !== 'string') { + return; + } + + transcriptLines.push(transcriptContent.trim()); + if (transcriptLines.length > MAX_TRANSCRIPT_LINES) { + transcriptLines.shift(); + } + + realtimeTranscriptsElm.textContent = transcriptLines.join('\n'); +} + +if (clearTranscriptsButton) { + clearTranscriptsButton.addEventListener('click', () => { + transcriptLines.length = 0; + realtimeTranscriptsElm.textContent = 'No transcripts received'; + }); +} + function isIncomingTask(task, agentId) { const taskData = task?.data; const taskState = taskData?.interaction?.state; @@ -1164,6 +1190,10 @@ function isInteractionOnHold(task) { // Register task listeners function registerTaskListeners(task) { + task.on('REAL_TIME_TRANSCRIPTION', (payload) => { + appendRealtimeTranscript(payload); + }); + task.on('task:assigned', (task) => { updateTaskList(); // Update the task list UI to have latest tasks console.info('Call has been accepted for task: ', task.data.interactionId); diff --git a/docs/samples/contact-center/index.html b/docs/samples/contact-center/index.html index 6c51a891035..b6f3f351257 100644 --- a/docs/samples/contact-center/index.html +++ b/docs/samples/contact-center/index.html @@ -305,6 +305,16 @@

+
+
+ Real-time Transcripts +
+ Transcript chunks received from realtime transcription events + +
+
No transcripts received
+
+
diff --git a/packages/@webex/contact-center/src/cc.ts b/packages/@webex/contact-center/src/cc.ts index e5b9a21601d..d9033e1d8b7 100644 --- a/packages/@webex/contact-center/src/cc.ts +++ b/packages/@webex/contact-center/src/cc.ts @@ -366,8 +366,10 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter this.services.webSocketManager.on('message', this.handleWebsocketMessage); this.webCallingService = new WebCallingService(this.$webex); + this.apiAIAssistant = new ApiAIAssistant(this.$webex); this.metricsManager = MetricsManager.getInstance({webex: this.$webex}); this.taskManager = TaskManager.getTaskManager( + this.apiAIAssistant, this.services.contact, this.webCallingService, this.services.webSocketManager @@ -379,9 +381,6 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter this.entryPoint = new EntryPoint(this.$webex); this.addressBook = new AddressBook(this.$webex, () => this.agentConfig?.addressBookId); this.queue = new Queue(this.$webex); - this.apiAIAssistant = new ApiAIAssistant(this.$webex, () => this.agentConfig); - - // Initialize logger LoggerProxy.initialize(this.$webex.logger); }); } @@ -728,6 +727,7 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter this.taskManager.setWrapupData(this.agentConfig.wrapUpData); this.taskManager.setAgentId(this.agentConfig.agentId); this.taskManager.setWebRtcEnabled(this.agentConfig.webRtcEnabled); + this.apiAIAssistant.setAIFeatureFlags(this.agentConfig.aiFeature); if ( this.agentConfig.webRtcEnabled && @@ -1093,17 +1093,6 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter return; } - if (eventData.type === CC_EVENTS.REAL_TIME_TRANSCRIPTION) { - const interactionId = - eventData?.data?.data?.interactionId || eventData?.data?.data?.conversationId; - if (interactionId) { - const task = this.taskManager.getTask(interactionId); - if (task) { - task.emit(CC_EVENTS.REAL_TIME_TRANSCRIPTION, eventData.data); - } - } - } - LoggerProxy.log(`Received event: ${eventData?.data?.type ?? eventData.type}`, { module: CC_FILE, method: METHODS.HANDLE_WEBSOCKET_MESSAGE, diff --git a/packages/@webex/contact-center/src/constants.ts b/packages/@webex/contact-center/src/constants.ts index 3a0cf63ddac..567145ea072 100644 --- a/packages/@webex/contact-center/src/constants.ts +++ b/packages/@webex/contact-center/src/constants.ts @@ -61,4 +61,6 @@ export const METHODS = { TOGGLE_MUTE: 'toggleMute', COMPLETE_TRANSFER: 'completeTransfer', GET_OUTDIAL_ANI_ENTRIES: 'getOutdialAniEntries', + SEND_EVENT: 'sendEvent', + FETCH_HISTORIC_TRANSCRIPTS: 'fetchHistoricTranscripts', }; diff --git a/packages/@webex/contact-center/src/index.ts b/packages/@webex/contact-center/src/index.ts index bfef235e46c..db87d10d3f9 100644 --- a/packages/@webex/contact-center/src/index.ts +++ b/packages/@webex/contact-center/src/index.ts @@ -41,11 +41,6 @@ export type { ContactServiceQueueSearchParams, ContactServiceQueue, } from './types'; -export type { - HistoricTranscriptsResponse, - TranscriptMessage, - TranscriptAction, -} from './services/ApiAiAssistant'; // Enums /** diff --git a/packages/@webex/contact-center/src/metrics/constants.ts b/packages/@webex/contact-center/src/metrics/constants.ts index 853ada894e6..a3e718cd5b0 100644 --- a/packages/@webex/contact-center/src/metrics/constants.ts +++ b/packages/@webex/contact-center/src/metrics/constants.ts @@ -163,9 +163,9 @@ export const METRIC_EVENT_NAMES = { OUTDIAL_ANI_EP_FETCH_SUCCESS: 'Outdial ANI Entries Fetch Success', OUTDIAL_ANI_EP_FETCH_FAILED: 'Outdial ANI Entries Fetch Failed', - // AI Assistant transcript events - AI_ASSISTANT_SEND_TRANSCRIPT_EVENT_SUCCESS: 'AI Assistant Send Transcript Event Success', - AI_ASSISTANT_SEND_TRANSCRIPT_EVENT_FAILED: 'AI Assistant Send Transcript Event Failed', + // AI Assistant events + AI_ASSISTANT_SEND_EVENT_SUCCESS: 'AI Assistant Send Event Success', + AI_ASSISTANT_SEND_EVENT_FAILED: 'AI Assistant Send Event Failed', AI_ASSISTANT_FETCH_HISTORIC_TRANSCRIPTS_SUCCESS: 'AI Assistant Fetch Historic Transcripts Success', AI_ASSISTANT_FETCH_HISTORIC_TRANSCRIPTS_FAILED: 'AI Assistant Fetch Historic Transcripts Failed', diff --git a/packages/@webex/contact-center/src/services/ApiAiAssistant.ts b/packages/@webex/contact-center/src/services/ApiAiAssistant.ts index 26c7b15ed36..3aeb269fdf8 100644 --- a/packages/@webex/contact-center/src/services/ApiAiAssistant.ts +++ b/packages/@webex/contact-center/src/services/ApiAiAssistant.ts @@ -1,33 +1,19 @@ import LoggerProxy from '../logger-proxy'; import MetricsManager from '../metrics/MetricsManager'; import {METRIC_EVENT_NAMES} from '../metrics/constants'; -import {CC_FILE} from '../constants'; -import {HTTP_METHODS, WebexSDK, IHttpResponse} from '../types'; +import {CC_FILE, METHODS} from '../constants'; +import { + HTTP_METHODS, + WebexSDK, + IHttpResponse, + TranscriptAction, + AIAssistantEventType, + AIAssistantEventName, + HistoricTranscriptsResponse, +} from '../types'; import {getErrorDetails} from './core/Utils'; -import {Profile} from './config/types'; - -const METHODS = { - SEND_TRANSCRIPT_EVENT: 'sendTranscriptEvent', - FETCH_HISTORIC_TRANSCRIPTS: 'fetchHistoricTranscripts', -} as const; - -export type TranscriptAction = 'START' | 'STOP'; - -export type TranscriptMessage = { - role: string; - content: string; - messageId: string; - publishTimestamp: number; -}; - -export type HistoricTranscriptsResponse = { - orgId: string; - agentId: string; - conversationId: string | null; - interactionId: string; - source: string; - data: TranscriptMessage[]; -}; +import {WCC_API_GATEWAY} from './constants'; +import {AIFeatureFlags} from './config/types'; /** * ApiAIAssistant provides AI Assistant APIs for transcript controls. @@ -36,58 +22,86 @@ export type HistoricTranscriptsResponse = { export class ApiAIAssistant { private webex: WebexSDK; private metricsManager: MetricsManager; - private getAgentConfig: () => Profile | undefined; + private aiFeature: AIFeatureFlags; + private orgId: string; - constructor(webex: WebexSDK, getAgentConfig: () => Profile | undefined) { + constructor(webex: WebexSDK) { this.webex = webex; this.metricsManager = MetricsManager.getInstance({webex}); - this.getAgentConfig = getAgentConfig; + this.orgId = this.webex.credentials.getOrgId(); + } + + public setAIFeatureFlags(aiFeature: AIFeatureFlags): void { + this.aiFeature = aiFeature; } private getBaseUrl(): string { - const profile = this.getAgentConfig(); - const aiAssistantBaseUrl = profile?.aiFeature?.aiAssistantBaseUrl; - if (!aiAssistantBaseUrl) { + let wccApiGatewayUrl = ''; + try { + wccApiGatewayUrl = this.webex.internal.services.get(WCC_API_GATEWAY) || ''; + } catch (_error) { + wccApiGatewayUrl = ''; + } + if (!wccApiGatewayUrl) { throw new Error('AI_ASSISTANT_BASE_URL_NOT_AVAILABLE'); } - return aiAssistantBaseUrl; - } - - private getRequiredAgentContext(): {agentId: string; orgId: string} { - const profile = this.getAgentConfig(); - const agentId = profile?.agentId; - const orgId = this.webex.credentials.getOrgId(); + let hostname = ''; + try { + hostname = new URL(wccApiGatewayUrl).hostname.toLowerCase(); + } catch (_error) { + hostname = wccApiGatewayUrl.toLowerCase(); + } - if (!agentId || !orgId) { - throw new Error('AGENT_CONTEXT_NOT_AVAILABLE'); + const envMap: Record = { + 'api.intgus1.ciscoccservice.com': 'intgus1', + 'api.qaus1.ciscoccservice.com': 'qaus1', + 'api.wxcc-us1.cisco.com': 'produs1', + 'api.wxcc-eu1.cisco.com': 'prodeu1', + 'api.wxcc-eu2.cisco.com': 'prodeu2', + 'api.wxcc-anz1.cisco.com': 'prodanz1', + 'api.wxcc-ca1.cisco.com': 'prodca1', + 'api.wxcc-jp1.cisco.com': 'prodjp1', + 'api.wxcc-sg1.cisco.com': 'prodsg1', + 'api.wxcc-in1.cisco.com': 'prodin1', + 'api.loadus1.cisco.com': 'loadus1', + }; + + const resolvedEnv = envMap[hostname]; + if (!resolvedEnv) { + throw new Error('AI_ASSISTANT_BASE_URL_NOT_AVAILABLE'); } - return {agentId, orgId}; + return `https://api-ai-assistant.${resolvedEnv}.ciscoccservice.com`; } /** - * Sends transcript start/stop event for an interaction. + * Sends an event to the AI Assistant service. + * @param agentId - agent identifier * @param interactionId - interaction/conversation identifier - * @param action - START or STOP + * @param eventType - the type of event (e.g. 'CUSTOM_EVENT') + * @param eventName - the name of the event (e.g. 'GET_TRANSCRIPTS') + * @param action - action within eventDetails (e.g. 'START' or 'STOP') */ - public async sendTranscriptEvent( + public async sendEvent( + agentId: string, interactionId: string, + eventType: AIAssistantEventType, + eventName: AIAssistantEventName, action: TranscriptAction ): Promise> { - LoggerProxy.info('Sending transcript event', { + LoggerProxy.info('Sending event', { module: CC_FILE, - method: METHODS.SEND_TRANSCRIPT_EVENT, + method: METHODS.SEND_EVENT, interactionId, - data: {action}, + data: {eventType, eventName, action}, }); this.metricsManager.timeEvent([ - METRIC_EVENT_NAMES.AI_ASSISTANT_SEND_TRANSCRIPT_EVENT_SUCCESS, - METRIC_EVENT_NAMES.AI_ASSISTANT_SEND_TRANSCRIPT_EVENT_FAILED, + METRIC_EVENT_NAMES.AI_ASSISTANT_SEND_EVENT_SUCCESS, + METRIC_EVENT_NAMES.AI_ASSISTANT_SEND_EVENT_FAILED, ]); try { - const {agentId, orgId} = this.getRequiredAgentContext(); const baseUrl = this.getBaseUrl(); const response = (await this.webex.request({ uri: `${baseUrl}/event`, @@ -95,9 +109,9 @@ export class ApiAIAssistant { addAuthHeader: true, body: { agentId, - orgId, - eventType: 'CUSTOM_EVENT', - eventName: 'GET_TRANSCRIPTS', + orgId: this.orgId, + eventType, + eventName, eventDetails: { data: { interactionId, @@ -109,23 +123,25 @@ export class ApiAIAssistant { })) as IHttpResponse; this.metricsManager.trackEvent( - METRIC_EVENT_NAMES.AI_ASSISTANT_SEND_TRANSCRIPT_EVENT_SUCCESS, - {agentId, orgId, interactionId, action}, + METRIC_EVENT_NAMES.AI_ASSISTANT_SEND_EVENT_SUCCESS, + {agentId, orgId: this.orgId, interactionId, eventType, eventName, action}, ['operational'] ); return response?.body || {}; } catch (error) { this.metricsManager.trackEvent( - METRIC_EVENT_NAMES.AI_ASSISTANT_SEND_TRANSCRIPT_EVENT_FAILED, + METRIC_EVENT_NAMES.AI_ASSISTANT_SEND_EVENT_FAILED, { interactionId, + eventType, + eventName, action, error: error instanceof Error ? error.message : String(error), }, ['operational'] ); - const {error: detailedError} = getErrorDetails(error, METHODS.SEND_TRANSCRIPT_EVENT, CC_FILE); + const {error: detailedError} = getErrorDetails(error, METHODS.SEND_EVENT, CC_FILE); throw detailedError; } } @@ -137,6 +153,7 @@ export class ApiAIAssistant { * @param interactionId - interaction/conversation identifier */ public async fetchHistoricTranscripts( + agentId: string, interactionId: string ): Promise { LoggerProxy.info('Fetching historic transcripts', { @@ -150,16 +167,10 @@ export class ApiAIAssistant { ]); try { - const profile = this.getAgentConfig(); - const featureEnabled = Boolean( - profile?.aiFeature?.realTimeTranscriptionEnabled ?? - profile?.['ai-feature']?.realTimeTranscriptionEnabled - ); - if (!featureEnabled) { + if (!this.aiFeature?.realtimeTranscripts?.enable) { throw new Error('REAL_TIME_TRANSCRIPTION_NOT_ENABLED'); } - const {agentId, orgId} = this.getRequiredAgentContext(); const baseUrl = this.getBaseUrl(); const response = (await this.webex.request({ uri: `${baseUrl}/transcripts/list`, @@ -167,14 +178,14 @@ export class ApiAIAssistant { addAuthHeader: true, body: { agentId, - orgId, + orgId: this.orgId, interactionId, }, })) as IHttpResponse; this.metricsManager.trackEvent( METRIC_EVENT_NAMES.AI_ASSISTANT_FETCH_HISTORIC_TRANSCRIPTS_SUCCESS, - {agentId, orgId, interactionId}, + {agentId, orgId: this.orgId, interactionId}, ['operational'] ); diff --git a/packages/@webex/contact-center/src/services/config/Util.ts b/packages/@webex/contact-center/src/services/config/Util.ts index ad0ea6c44b0..89ba89a6639 100644 --- a/packages/@webex/contact-center/src/services/config/Util.ts +++ b/packages/@webex/contact-center/src/services/config/Util.ts @@ -1,7 +1,6 @@ import {LOST_CONNECTION_RECOVERY_TIMEOUT} from '../core/constants'; import { AgentResponse, - ListAIFeatureResourcesResponse, AuxCode, AuxCodeType, DesktopProfileResponse, @@ -15,6 +14,8 @@ import { TenantData, URLMapping, WRAP_UP_CODE, + AIFeatureFlagsResponse, + AIFeatureFlags, } from './types'; /** @@ -62,44 +63,6 @@ const getDefaultAgentDN = (agentDNValidation: string) => { return agentDNValidation === 'PROVISIONED_VALUE'; }; -/** - * Resolve AI Assistant base URL from discovered WCC API gateway URL. - * The AI Assistant service is not in U2C catalog yet, so we derive env manually. - * - * @param {string} wccApiGatewayUrl - * @returns {string} - */ -const getAIAssistantBaseUrl = (wccApiGatewayUrl: string) => { - if (!wccApiGatewayUrl) { - return ''; - } - - let hostname = ''; - try { - hostname = new URL(wccApiGatewayUrl).hostname.toLowerCase(); - } catch (_error) { - hostname = wccApiGatewayUrl.toLowerCase(); - } - - const envMap: Record = { - 'api.intgus1.ciscoccservice.com': 'intgus1', - 'api.qaus1.ciscoccservice.com': 'qaus1', - 'api.wxcc-us1.cisco.com': 'produs1', - 'api.wxcc-eu1.cisco.com': 'prodeu1', - 'api.wxcc-eu2.cisco.com': 'prodeu2', - 'api.wxcc-anz1.cisco.com': 'prodanz1', - 'api.wxcc-ca1.cisco.com': 'prodca1', - 'api.wxcc-jp1.cisco.com': 'prodjp1', - 'api.wxcc-sg1.cisco.com': 'prodsg1', - 'api.wxcc-in1.cisco.com': 'prodin1', - 'api.loadus1.cisco.com': 'loadus1', - }; - - const resolvedEnv = envMap[hostname]; - - return resolvedEnv ? `https://api-ai-assistant.${resolvedEnv}.ciscoccservice.com` : ''; -}; - /** * Get the filtered dialplan entries * @param {Array} dialPlanData @@ -179,8 +142,7 @@ function parseAgentConfigs(profileData: { dialPlanData: DialPlanEntity[]; urlMapping: URLMapping[]; multimediaProfileId: string; - aiFeatureResources: ListAIFeatureResourcesResponse; - wccApiGatewayUrl: string; + aiFeatureFlags: AIFeatureFlagsResponse; }): Profile { const { userData, @@ -192,8 +154,7 @@ function parseAgentConfigs(profileData: { agentProfileData, dialPlanData, urlMapping, - aiFeatureResources, - wccApiGatewayUrl, + aiFeatureFlags, } = profileData; const tenantDataTimeout = tenantData.timeoutDesktopInactivityEnabled @@ -223,10 +184,8 @@ function parseAgentConfigs(profileData: { }); // pushing available state to idle codes const defaultWrapUpData = getDefaultWrapUpCode(wrapupCodes); - const realTimeTranscriptionEnabled = Boolean( - aiFeatureResources?.data?.[0]?.realtimeTranscripts?.enable - ); - const aiAssistantBaseUrl = getAIAssistantBaseUrl(wccApiGatewayUrl); + const aiFeature: AIFeatureFlags | undefined = + aiFeatureFlags?.data && aiFeatureFlags.data.length > 0 ? aiFeatureFlags.data[0] : undefined; const finalData = { teams: teamData, @@ -300,13 +259,7 @@ function parseAgentConfigs(profileData: { webexConfig: getWebexConfig(agentProfileData), lostConnectionRecoveryTimeout: tenantData.lostConnectionRecoveryTimeout || LOST_CONNECTION_RECOVERY_TIMEOUT, - aiFeature: { - realTimeTranscriptionEnabled, - aiAssistantBaseUrl, - }, - 'ai-feature': { - realTimeTranscriptionEnabled, - }, + aiFeature, }; return finalData; diff --git a/packages/@webex/contact-center/src/services/config/constants.ts b/packages/@webex/contact-center/src/services/config/constants.ts index 309c3d261db..545ade2f04a 100644 --- a/packages/@webex/contact-center/src/services/config/constants.ts +++ b/packages/@webex/contact-center/src/services/config/constants.ts @@ -67,7 +67,7 @@ export const METHODS = { GET_TENANT_DATA: 'getTenantData', GET_URL_MAPPING: 'getURLMapping', GET_DIAL_PLAN_DATA: 'getDialPlanData', - GET_AI_FEATURE_RESOURCES: 'getAIFeatureResources', + GET_AI_FEATURE_FLAGS: 'getAIFeatureFlags', GET_QUEUES: 'getQueues', // Util methods @@ -235,15 +235,15 @@ export const endPointMap = { */ dialPlan: (orgId: string) => `organization/${orgId}/dial-plan?agentView=true`, /** - * Gets the endpoint for listing AI feature resources. + * Gets the endpoint for listing AI feature flags. * @param orgId - Organization ID. * @returns The endpoint URL string. * @public * @example - * const url = endPointMap.aiFeatureResources('org123'); + * const url = endPointMap.aiFeatureFlags('org123'); * @ignore */ - aiFeatureResources: (orgId: string) => `organization/${orgId}/v2/ai-feature?page=0&pageSize=100`, + aiFeature: (orgId: string) => `organization/${orgId}/v2/ai-feature?page=0&pageSize=100`, /** * Gets the endpoint for the queue list with custom query parameters. diff --git a/packages/@webex/contact-center/src/services/config/index.ts b/packages/@webex/contact-center/src/services/config/index.ts index f714d234aa3..dfccb5dea08 100644 --- a/packages/@webex/contact-center/src/services/config/index.ts +++ b/packages/@webex/contact-center/src/services/config/index.ts @@ -8,7 +8,6 @@ import LoggerProxy from '../../logger-proxy'; import { DesktopProfileResponse, ListAuxCodesResponse, - ListAIFeatureResourcesResponse, AgentResponse, TenantData, OrgInfo, @@ -23,6 +22,7 @@ import { SiteInfo, OutdialAniEntriesResponse, OutdialAniParams, + AIFeatureFlagsResponse, } from './types'; import WebexRequest from '../core/WebexRequest'; import {WCC_API_GATEWAY} from '../constants'; @@ -62,7 +62,7 @@ export default class AgentConfigService { const orgSettingsPromise = this.getOrganizationSetting(orgId); const tenantDataPromise = this.getTenantData(orgId); const urlMappingPromise = this.getURLMapping(orgId); - const aiFeatureResourcesPromise = this.getAIFeatureResources(orgId); + const aiFeatureFlagsPromise = this.getAIFeatureFlags(orgId); const auxCodesPromise = this.getAllAuxCodes( orgId, DEFAULT_PAGE_SIZE, @@ -96,7 +96,7 @@ export default class AgentConfigService { orgSettingsData, tenantData, urlMappingData, - aiFeatureResources, + aiFeatureFlagsData, auxCodesData, ] = await Promise.all([ agentProfilePromise, @@ -107,11 +107,9 @@ export default class AgentConfigService { orgSettingsPromise, tenantDataPromise, urlMappingPromise, - aiFeatureResourcesPromise, + aiFeatureFlagsPromise, auxCodesPromise, ]); - const wccApiGatewayUrl = this.webexReq.getServiceUrl(WCC_API_GATEWAY); - const multimediaProfileId = userConfigData.multimediaProfileId || userTeamData[0]?.multiMediaProfileId || @@ -133,8 +131,7 @@ export default class AgentConfigService { dialPlanData: userDialPlanData, urlMapping: urlMappingData, multimediaProfileId, - aiFeatureResources, - wccApiGatewayUrl, + aiFeatureFlags: aiFeatureFlagsData, }); LoggerProxy.info('Parsing completed for agent-config', { @@ -662,17 +659,17 @@ export default class AgentConfigService { * Fetches AI feature resources for the organization. * @ignore * @param {string} orgId - organization ID for which AI feature resources are to be fetched. - * @returns {Promise} - AI feature resources response. + * @returns {Promise} - AI feature resources response. * @throws {Error} - Throws an error if the API call fails or if the response status is not 200. * @private */ - public async getAIFeatureResources(orgId: string): Promise { + public async getAIFeatureFlags(orgId: string): Promise { LoggerProxy.info('Fetching AI feature resources', { module: CONFIG_FILE_NAME, - method: METHODS.GET_AI_FEATURE_RESOURCES, + method: METHODS.GET_AI_FEATURE_FLAGS, }); try { - const resource = endPointMap.aiFeatureResources(orgId); + const resource = endPointMap.aiFeature(orgId); const response = await this.webexReq.request({ service: WCC_API_GATEWAY, resource, @@ -683,16 +680,16 @@ export default class AgentConfigService { throw new Error(`API call failed with ${response.statusCode}`); } - LoggerProxy.log('getAIFeatureResources api success.', { + LoggerProxy.log('getAIFeatureFlags api success.', { module: CONFIG_FILE_NAME, - method: METHODS.GET_AI_FEATURE_RESOURCES, + method: METHODS.GET_AI_FEATURE_FLAGS, }); return Promise.resolve(response.body); } catch (error) { - LoggerProxy.error(`getAIFeatureResources API call failed with ${error}`, { + LoggerProxy.error(`getAIFeatureFlags API call failed with ${error}`, { module: CONFIG_FILE_NAME, - method: METHODS.GET_AI_FEATURE_RESOURCES, + method: METHODS.GET_AI_FEATURE_FLAGS, }); throw error; } diff --git a/packages/@webex/contact-center/src/services/config/types.ts b/packages/@webex/contact-center/src/services/config/types.ts index 8e240a4666f..827c1561177 100644 --- a/packages/@webex/contact-center/src/services/config/types.ts +++ b/packages/@webex/contact-center/src/services/config/types.ts @@ -1000,6 +1000,59 @@ export type URLMappings = { acqueonConsoleUrl: string; }; +/** + * AI feature resource row returned by /v2/ai-feature API. + * @public + */ +export type AIFeatureFlags = { + id: string; + realtimeTranscripts?: { + enable?: boolean; + agentInclusionType?: string; + }; + suggestedResponses?: { + enable?: boolean; + }; + generatedSummaries?: { + callDropSummariesEnabled?: boolean; + virtualAgentTransferSummariesEnabled?: boolean; + consultTransferSummariesEnabled?: boolean; + wrapUpSummariesEnabled?: boolean; + queuesInclusionType?: string; + }; + agentWellbeing?: { + enable?: boolean; + agentInclusionType?: string; + wellnessBreakReminders?: string; + }; + autoCSAT?: { + enable?: boolean; + queuesInclusionType?: string; + surveyDataSource?: string; + }; + links?: string[]; + createdTime?: number; + lastUpdatedTime?: number; +}; + +/** + * Response type for list AI feature resources API. + * @public + */ +export type AIFeatureFlagsResponse = { + meta?: { + orgid?: string; + page?: number; + pageSize?: number; + totalPages?: number; + totalRecords?: number; + links?: { + self?: string; + }; + }; + data: AIFeatureFlags[]; +}; + /** * Comprehensive agent profile configuration in the contact center system * Contains all settings and capabilities for an agent @@ -1156,70 +1209,7 @@ export type Profile = { /** Timestamp of last idle code change */ lastIdleCodeChangeTimestamp?: number; /** AI feature flags resolved from organization config */ - aiFeature?: { - /** Whether real-time transcription is enabled for this agent/org */ - realTimeTranscriptionEnabled: boolean; - /** Base URL for AI Assistant APIs derived from WCC gateway environment */ - aiAssistantBaseUrl?: string; - }; - /** Legacy key preserved for compatibility with dashboard/client integrations */ - 'ai-feature'?: { - /** Whether real-time transcription is enabled for this agent/org */ - realTimeTranscriptionEnabled: boolean; - }; -}; - -/** - * AI feature resource row returned by /v2/ai-feature API. - * @public - */ -export type AIFeatureResource = { - id: string; - realtimeTranscripts?: { - enable?: boolean; - agentInclusionType?: string; - }; - suggestedResponses?: { - enable?: boolean; - }; - generatedSummaries?: { - callDropSummariesEnabled?: boolean; - virtualAgentTransferSummariesEnabled?: boolean; - consultTransferSummariesEnabled?: boolean; - wrapUpSummariesEnabled?: boolean; - queuesInclusionType?: string; - }; - agentWellbeing?: { - enable?: boolean; - agentInclusionType?: string; - wellnessBreakReminders?: string; - }; - autoCSAT?: { - enable?: boolean; - queuesInclusionType?: string; - surveyDataSource?: string; - }; - links?: string[]; - createdTime?: number; - lastUpdatedTime?: number; -}; - -/** - * Response type for list AI feature resources API. - * @public - */ -export type ListAIFeatureResourcesResponse = { - meta?: { - orgid?: string; - page?: number; - pageSize?: number; - totalPages?: number; - totalRecords?: number; - links?: { - self?: string; - }; - }; - data: AIFeatureResource[]; + aiFeature?: AIFeatureFlags; }; /** diff --git a/packages/@webex/contact-center/src/services/task/TaskManager.ts b/packages/@webex/contact-center/src/services/task/TaskManager.ts index cf7ba057049..786f5ffaa9b 100644 --- a/packages/@webex/contact-center/src/services/task/TaskManager.ts +++ b/packages/@webex/contact-center/src/services/task/TaskManager.ts @@ -17,17 +17,27 @@ import { import {TASK_MANAGER_FILE} from '../../constants'; import {METHODS} from './constants'; import {CC_EVENTS, WrapupData} from '../config/types'; -import {ConfigFlags, LoginOption} from '../../types'; +import {ConfigFlags, LoginOption, TranscriptAction} from '../../types'; import LoggerProxy from '../../logger-proxy'; import {getIsConferenceInProgress, isSecondaryEpDnAgent, shouldAutoAnswerTask} from './TaskUtils'; import TaskFactory from './TaskFactory'; import WebRTC from './voice/WebRTC'; import {TaskEvent, type TaskEventPayload} from './state-machine'; import {normalizeTaskData} from './taskDataNormalizer'; +import {ApiAIAssistant} from '../ApiAiAssistant'; const CC_EVENT_SET = new Set(Object.values(CC_EVENTS) as CC_EVENTS[]); const isCcEvent = (value: string): value is CC_EVENTS => CC_EVENT_SET.has(value as CC_EVENTS); + +const TRANSCRIPT_EVENT_MAP: Record = { + [CC_EVENTS.AGENT_CONTACT_ASSIGNED]: 'START', + [CC_EVENTS.AGENT_CONSULTING]: 'START', + [CC_EVENTS.AGENT_CONSULT_CONFERENCED]: 'START', + [CC_EVENTS.AGENT_WRAPUP]: 'STOP', + [CC_EVENTS.AGENT_CONSULT_ENDED]: 'STOP', + [CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE]: 'STOP', +}; /** @internal */ export default class TaskManager extends EventEmitter { private call: ICall; @@ -46,17 +56,20 @@ export default class TaskManager extends EventEmitter { private wrapupData: WrapupData; private agentId: string; private webRtcEnabled: boolean; + private apiAIAssistant?: ApiAIAssistant; /** * @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( + apiAIAssistant: ApiAIAssistant, contact: ReturnType, webCallingService: WebCallingService, webSocketManager: WebSocketManager ) { super(); + this.apiAIAssistant = apiAIAssistant; this.contact = contact; this.webCallingService = webCallingService; this.webSocketManager = webSocketManager; @@ -328,6 +341,14 @@ export default class TaskManager extends EventEmitter { const {payload, stateMachineEvent} = eventContext; + if (eventContext.eventType === CC_EVENTS.REAL_TIME_TRANSCRIPTION) { + task.emit(CC_EVENTS.REAL_TIME_TRANSCRIPTION, payload); + // Backward-compatible alias consumed by existing sample apps. + task.emit(CC_EVENTS.REAL_TIME_TRANSCRIPTION, payload); + + return; + } + // Always keep task.data updated (even for mapped events) so consumers relying // on TaskManager-managed task instances see the latest payload. if (payload) { @@ -339,6 +360,9 @@ export default class TaskManager extends EventEmitter { if (stateMachineEvent) { task.sendStateMachineEvent(stateMachineEvent); } + + // Send transcript start/stop events for relevant CC events + this.requestRealTimeTranscripts(eventContext.eventType, payload.interactionId); }); } @@ -678,6 +702,26 @@ export default class TaskManager extends EventEmitter { } } + /** + * Sends transcript start/stop event based on the CC event type. + * Fire-and-forget; errors are logged but do not interrupt event processing. + */ + private requestRealTimeTranscripts(eventType: string, interactionId: string): void { + const action = TRANSCRIPT_EVENT_MAP[eventType]; + if (!action || !this.apiAIAssistant) return; + + this.apiAIAssistant + .sendEvent(this.agentId, interactionId, 'CUSTOM_EVENT', 'GET_TRANSCRIPTS', action) + .catch((error) => { + LoggerProxy.error(`Failed to send transcript ${action} event`, { + module: TASK_MANAGER_FILE, + method: 'requestRealTimeTranscripts', + interactionId, + error, + }); + }); + } + public getTask(taskId: TaskId): ITask { return this.taskCollection[taskId]; } @@ -687,12 +731,18 @@ export default class TaskManager extends EventEmitter { } public static getTaskManager( + apiAIAssistant: ApiAIAssistant, contact: ReturnType, webCallingService: WebCallingService, webSocketManager: WebSocketManager ): TaskManager { if (!TaskManager.taskManager) { - TaskManager.taskManager = new TaskManager(contact, webCallingService, webSocketManager); + TaskManager.taskManager = new TaskManager( + apiAIAssistant, + contact, + webCallingService, + webSocketManager + ); } return TaskManager.taskManager; diff --git a/packages/@webex/contact-center/src/services/task/types.ts b/packages/@webex/contact-center/src/services/task/types.ts index 1f4869f047e..5de02d9bc48 100644 --- a/packages/@webex/contact-center/src/services/task/types.ts +++ b/packages/@webex/contact-center/src/services/task/types.ts @@ -1873,6 +1873,7 @@ export type WebSocketPayload = TaskData & { export type WebSocketMessage = { keepalive?: 'true' | 'false' | boolean; + type?: string; data: WebSocketPayload; }; diff --git a/packages/@webex/contact-center/src/types.ts b/packages/@webex/contact-center/src/types.ts index 09133f90137..8667d8843eb 100644 --- a/packages/@webex/contact-center/src/types.ts +++ b/packages/@webex/contact-center/src/types.ts @@ -835,3 +835,31 @@ export type BuddyAgentsResponse = Agent.BuddyAgentsSuccess | Error; * function handleUpdateDeviceType(resp: UpdateDeviceTypeResponse) { ... } */ export type UpdateDeviceTypeResponse = Agent.DeviceTypeUpdateSuccess | Error; + +export type TranscriptAction = 'START' | 'STOP'; + +export type AIAssistantEventType = 'CUSTOM_EVENT' | 'CTI_EVENT'; + +export type AIAssistantEventName = + | 'GET_TRANSCRIPTS' + | 'GET_MID_CALL_SUMMARY' + | 'GET_POST_CALL_SUMMARY' + | 'MID_CALL_SUMMARY_RESPONSE' + | 'POST_CALL_SUMMARY_RESPONSE' + | 'SUGGESTED_RESPONSES_DIGITAL'; + +export type TranscriptMessage = { + role: string; + content: string; + messageId: string; + publishTimestamp: number; +}; + +export type HistoricTranscriptsResponse = { + orgId: string; + agentId: string; + conversationId: string | null; + interactionId: string; + source: string; + data: TranscriptMessage[]; +}; diff --git a/packages/@webex/contact-center/test/unit/spec/cc.ts b/packages/@webex/contact-center/test/unit/spec/cc.ts index 4c2ee313651..b332129b860 100644 --- a/packages/@webex/contact-center/test/unit/spec/cc.ts +++ b/packages/@webex/contact-center/test/unit/spec/cc.ts @@ -130,6 +130,10 @@ describe('webex.cc', () => { dialer: { startOutdial: jest.fn(), }, + apiAIAssistant: { + sendTranscriptEvent: jest.fn(), + setAIFeatureFlags: jest.fn(), + }, }; mockTaskManager = { @@ -143,6 +147,7 @@ describe('webex.cc', () => { setWrapupData: jest.fn(), setAgentId: jest.fn(), setWebRtcEnabled: jest.fn(), + setApiAIAssistant: jest.fn(), registerIncomingCallEvent: jest.fn(), registerTaskListeners: jest.fn(), getTask: jest.fn(), @@ -185,6 +190,13 @@ describe('webex.cc', () => { webex.emit('ready'); }); + it('should initialize TaskManager with apiAIAssistant dependency', () => { + const calls = (TaskManager.getTaskManager as jest.Mock).mock.calls; + + expect(calls.length).toBeGreaterThan(0); + expect(calls[0][0]).toBe(webex.cc.services.apiAIAssistant); + }); + describe('cc.getDeviceId', () => { it('should return dialNumber when loginOption is EXTENSION', () => { const loginOption = LoginOption.EXTENSION; diff --git a/packages/@webex/contact-center/test/unit/spec/services/ApiAiAssistant.ts b/packages/@webex/contact-center/test/unit/spec/services/ApiAiAssistant.ts new file mode 100644 index 00000000000..a6e5863172d --- /dev/null +++ b/packages/@webex/contact-center/test/unit/spec/services/ApiAiAssistant.ts @@ -0,0 +1,115 @@ +import ApiAIAssistant from '../../../../src/services/ApiAiAssistant'; +import MetricsManager from '../../../../src/metrics/MetricsManager'; +import LoggerProxy from '../../../../src/logger-proxy'; +import {HTTP_METHODS, WebexSDK} from '../../../../src/types'; + +jest.mock('../../../../src/metrics/MetricsManager'); +jest.mock('../../../../src/logger-proxy'); + +describe('ApiAIAssistant', () => { + let apiAIAssistant: ApiAIAssistant; + let mockWebex: WebexSDK; + let mockMetricsManager: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + + mockWebex = { + credentials: { + getOrgId: jest.fn().mockReturnValue('test-org-id'), + }, + request: jest.fn(), + internal: { + services: { + get: jest.fn().mockReturnValue('https://api.wxcc-us1.cisco.com'), + }, + newMetrics: { + submitBehavioralEvent: jest.fn(), + submitOperationalEvent: jest.fn(), + submitBusinessEvent: jest.fn(), + }, + }, + ready: true, + once: jest.fn(), + } as unknown as WebexSDK; + + mockMetricsManager = { + trackEvent: jest.fn(), + timeEvent: jest.fn(), + } as unknown as jest.Mocked; + (MetricsManager.getInstance as jest.Mock).mockReturnValue(mockMetricsManager); + + apiAIAssistant = new ApiAIAssistant(mockWebex); + }); + + it('should send transcript start event successfully', async () => { + (mockWebex.request as jest.Mock).mockResolvedValue({body: {ok: true}}); + + const result = await apiAIAssistant.sendEvent( + 'test-agent-id', + 'interaction-1', + 'CUSTOM_EVENT', + 'GET_TRANSCRIPTS', + 'START' + ); + + expect(mockWebex.request).toHaveBeenCalledWith({ + uri: 'https://api-ai-assistant.produs1.ciscoccservice.com/event', + method: HTTP_METHODS.POST, + addAuthHeader: true, + body: { + agentId: 'test-agent-id', + orgId: 'test-org-id', + eventType: 'CUSTOM_EVENT', + eventName: 'GET_TRANSCRIPTS', + eventDetails: { + data: jasmine.objectContaining({ + interactionId: 'interaction-1', + action: 'START', + }), + }, + }, + }); + expect(result).toEqual({ok: true}); + }); + + it('should fetch historic transcripts with mapped base URL', async () => { + const responseBody = {interactionId: 'interaction-1', data: []}; + (mockWebex.request as jest.Mock).mockResolvedValue({body: responseBody}); + apiAIAssistant.setAIFeatureFlags({realtimeTranscripts: {enable: true}} as any); + + const result = await apiAIAssistant.fetchHistoricTranscripts('test-agent-id', 'interaction-1'); + + expect(mockWebex.request).toHaveBeenCalledWith({ + uri: 'https://api-ai-assistant.produs1.ciscoccservice.com/transcripts/list', + method: HTTP_METHODS.POST, + addAuthHeader: true, + body: { + agentId: 'test-agent-id', + orgId: 'test-org-id', + interactionId: 'interaction-1', + }, + }); + expect(result).toEqual(responseBody as any); + }); + + it('should fail when base URL mapping is not available', async () => { + (mockWebex.internal.services.get as jest.Mock).mockReturnValue('https://unknown-host.invalid'); + + let failed = false; + try { + await apiAIAssistant.sendEvent( + 'test-agent-id', + 'interaction-1', + 'CUSTOM_EVENT', + 'GET_TRANSCRIPTS', + 'STOP' + ); + } catch (_error) { + failed = true; + } + + expect(failed).toBe(true); + expect(LoggerProxy.error).toHaveBeenCalled(); + }); +}); diff --git a/packages/@webex/contact-center/test/unit/spec/services/config/index.ts b/packages/@webex/contact-center/test/unit/spec/services/config/index.ts index da67e6288a1..ef6580d4db5 100644 --- a/packages/@webex/contact-center/test/unit/spec/services/config/index.ts +++ b/packages/@webex/contact-center/test/unit/spec/services/config/index.ts @@ -750,6 +750,9 @@ describe('AgentConfigService', () => { {id: 'aux1', type: 'WRAP_UP_CODE', name: 'Wrap Up Code 1', isDefault: true}, {id: 'aux2', type: 'IDLE_CODE', name: 'Idle Code 1', isDefault: true}, ]; + const mockAIFeatureFlags = { + data: [{realtimeTranscripts: {enable: true}}], + }; const parseAgentConfigsSpy = jest.spyOn(util, 'parseAgentConfigs'); agentConfigService.getUserUsingCI = jest.fn().mockResolvedValue(mockUserConfig); @@ -758,6 +761,7 @@ describe('AgentConfigService', () => { agentConfigService.getSiteInfo = jest.fn().mockResolvedValue(mockSiteInfo); agentConfigService.getTenantData = jest.fn().mockResolvedValue(mockTenantData); agentConfigService.getURLMapping = jest.fn().mockResolvedValue(mockURLMapping); + agentConfigService.getAIFeatureFlags = jest.fn().mockResolvedValue(mockAIFeatureFlags); agentConfigService.getAllAuxCodes = jest.fn().mockResolvedValue(mockAuxCodes); agentConfigService.getDesktopProfileById = jest.fn().mockResolvedValue(mockAgentProfile); agentConfigService.getDialPlanData = jest.fn().mockResolvedValue(mockDialPlanData); @@ -797,6 +801,7 @@ describe('AgentConfigService', () => { dialPlanData: mockDialPlanData, urlMapping: mockURLMapping, multimediaProfileId: mockSiteInfo.multimediaProfileId, + aiFeatureFlags: mockAIFeatureFlags, }); }); @@ -890,6 +895,9 @@ describe('AgentConfigService', () => { {id: 'aux1', type: 'WRAP_UP_CODE', name: 'Wrap Up Code 1'}, {id: 'aux2', type: 'IDLE_CODE', name: 'Idle Code 1'}, ]; + const mockAIFeatureFlags = { + data: [{realtimeTranscripts: {enable: true}}], + }; const parseAgentConfigsSpy = jest.spyOn(util, 'parseAgentConfigs'); agentConfigService.getUserUsingCI = jest.fn().mockResolvedValue(mockUserConfig); @@ -898,6 +906,7 @@ describe('AgentConfigService', () => { agentConfigService.getSiteInfo = jest.fn().mockResolvedValue(mockSiteInfo); agentConfigService.getTenantData = jest.fn().mockResolvedValue(mockTenantData); agentConfigService.getURLMapping = jest.fn().mockResolvedValue(mockURLMapping); + agentConfigService.getAIFeatureFlags = jest.fn().mockResolvedValue(mockAIFeatureFlags); agentConfigService.getAllAuxCodes = jest.fn().mockResolvedValue(mockAuxCodes); agentConfigService.getDesktopProfileById = jest.fn().mockResolvedValue(mockAgentProfile); agentConfigService.getDialPlanData = jest.fn().mockResolvedValue(mockDialPlanData); @@ -937,6 +946,7 @@ describe('AgentConfigService', () => { dialPlanData: mockDialPlanData, urlMapping: mockURLMapping, multimediaProfileId: mockSiteInfo.multimediaProfileId, + aiFeatureFlags: mockAIFeatureFlags, }); }); @@ -948,6 +958,7 @@ describe('AgentConfigService', () => { agentConfigService.getOrganizationSetting = jest.fn().mockResolvedValue({}); agentConfigService.getTenantData = jest.fn().mockResolvedValue({}); agentConfigService.getURLMapping = jest.fn().mockResolvedValue({}); + agentConfigService.getAIFeatureFlags = jest.fn().mockResolvedValue({data: []}); agentConfigService.getAllAuxCodes = jest.fn().mockResolvedValue({}); agentConfigService.getDesktopProfileById = jest.fn().mockResolvedValue({}); agentConfigService.getDialPlanData = jest.fn().mockResolvedValue({}); 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 3c28d492582..793a8624b04 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 @@ -14,10 +14,10 @@ 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 LoggerProxy from '../../../../../src/logger-proxy'; describe('TaskManager', () => { let mockCall; + let mockApiAIAssistant; let webSocketManagerMock; let onSpy; let offSpy; @@ -188,10 +188,20 @@ describe('TaskManager', () => { onSpy = jest.spyOn(webCallingService, 'on'); offSpy = jest.spyOn(webCallingService, 'off'); - taskManager = new TaskManager(contactMock, webCallingService, webSocketManagerMock); + mockApiAIAssistant = { + sendEvent: jest.fn().mockResolvedValue({}), + }; + + taskManager = new TaskManager( + mockApiAIAssistant as any, + contactMock, + webCallingService, + webSocketManagerMock as any + ); taskManager.taskCollection[taskId] = createMockTask(taskDataMock); (taskManager as any).setupTaskListeners?.(taskManager.taskCollection[taskId]); taskManager.call = mockCall; + taskManager.setAgentId('test-agent-id'); jest .spyOn(TaskFactory, 'createTask') @@ -222,9 +232,7 @@ describe('TaskManager', () => { it('should re-emit task related events', () => { const dummyPayload = { - data: {...taskDataMock, - type: CC_TASK_EVENTS.AGENT_CONSULTING, - }, + data: {...taskDataMock, type: CC_TASK_EVENTS.AGENT_CONSULTING}, }; webSocketManagerMock.emit('message', JSON.stringify({data: taskDataMock})); const taskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit'); @@ -241,6 +249,63 @@ describe('TaskManager', () => { ); }); + it('should invoke sendTranscriptEvent for configured start/stop backend events', () => { + const interactionId = 'interaction-transcript-1'; + const message = (type: CC_EVENTS) => + JSON.stringify({ + data: { + ...taskDataMock, + interactionId, + type, + }, + }); + + webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONTACT_ASSIGNED)); + webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONSULTING)); + webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONSULT_CONFERENCED)); + webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_WRAPUP)); + webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONSULT_ENDED)); + webSocketManagerMock.emit('message', message(CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE)); + + expect(mockApiAIAssistant.sendEvent).toHaveBeenCalledTimes(6); + expect(mockApiAIAssistant.sendEvent).toHaveBeenCalledWith( + 'test-agent-id', + interactionId, + 'CUSTOM_EVENT', + 'GET_TRANSCRIPTS', + 'START' + ); + expect(mockApiAIAssistant.sendEvent).toHaveBeenCalledWith( + 'test-agent-id', + interactionId, + 'CUSTOM_EVENT', + 'GET_TRANSCRIPTS', + 'STOP' + ); + }); + + it('should emit REAL_TIME_TRANSCRIPTION from task object', () => { + const task = taskManager.getTask(taskId); + const taskEmitSpy = jest.spyOn(task, 'emit'); + const realtimePayload = { + data: { + ...taskDataMock, + type: CC_EVENTS.REAL_TIME_TRANSCRIPTION, + data: { + content: 'hello from transcript', + }, + }, + }; + + webSocketManagerMock.emit('message', JSON.stringify(realtimePayload)); + + expect(taskEmitSpy).toHaveBeenCalledWith( + CC_EVENTS.REAL_TIME_TRANSCRIPTION, + realtimePayload.data + ); + expect(taskEmitSpy).toHaveBeenCalledWith('realtimeTranscription', realtimePayload.data); + }); + it('should not re-emit agent related events', () => { const dummyPayload = { data: { @@ -303,7 +368,10 @@ describe('TaskManager', () => { }, }; - const currentTaskAssignedSpy = jest.spyOn(taskManager.getTask(payload.data.interactionId), 'emit'); + const currentTaskAssignedSpy = jest.spyOn( + taskManager.getTask(payload.data.interactionId), + 'emit' + ); webSocketManagerMock.emit('message', JSON.stringify(assignedPayload)); @@ -471,28 +539,26 @@ describe('TaskManager', () => { expect(allTasks).toHaveProperty(taskId2, mockTask2); }); - it('test call listeners being switched off on call end', () => { - webSocketManagerMock.emit('message', JSON.stringify(initalPayload)); - - const webrtcTask = new WebRTC( - contactMock, - webCallingService, - taskDataMock, - {isEndTaskEnabled: true, isEndConsultEnabled: true} - ); - (taskManager as any).taskCollection[taskId] = webrtcTask; - // TaskManager must listen to task-level cleanup events emitted by the state machine. - // This is normally wired when TaskManager creates the task via TaskFactory. - (taskManager as any).setupTaskListeners(webrtcTask); - - const task = taskManager.getTask(taskId)!; - // This test doesn't validate UI controls; avoid requiring full interaction.media - // shape for WebRTC UI controls computation. - jest.spyOn(task as any, 'updateUiControls').mockImplementation(() => undefined); - const originalEmit = task.emit; - jest.spyOn(task, 'emit').mockImplementation((event, arg) => { - if (event === CC_EVENTS.CONTACT_ENDED) { - return; + it('test call listeners being switched off on call end', () => { + webSocketManagerMock.emit('message', JSON.stringify(initalPayload)); + + const webrtcTask = new WebRTC(contactMock, webCallingService, taskDataMock, { + isEndTaskEnabled: true, + isEndConsultEnabled: true, + }); + (taskManager as any).taskCollection[taskId] = webrtcTask; + // TaskManager must listen to task-level cleanup events emitted by the state machine. + // This is normally wired when TaskManager creates the task via TaskFactory. + (taskManager as any).setupTaskListeners(webrtcTask); + + const task = taskManager.getTask(taskId)!; + // This test doesn't validate UI controls; avoid requiring full interaction.media + // shape for WebRTC UI controls computation. + jest.spyOn(task as any, 'updateUiControls').mockImplementation(() => undefined); + const originalEmit = task.emit; + jest.spyOn(task, 'emit').mockImplementation((event, arg) => { + if (event === CC_EVENTS.CONTACT_ENDED) { + return; } return originalEmit.call(task, event, arg); }); @@ -583,37 +649,36 @@ describe('TaskManager', () => { it('should emit TASK_REJECT event on AGENT_INVITE_FAILED event', () => { webSocketManagerMock.emit('message', JSON.stringify(initalPayload)); - const task = taskManager.getTask(taskId); - const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent'); - 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', - reason: 'INVITE_FAILED', - }, - }; + const task = taskManager.getTask(taskId); + const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent'); + 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', + reason: 'INVITE_FAILED', + }, + }; - task.updateTaskData(payload.data); - webSocketManagerMock.emit('message', JSON.stringify(payload)); - const stateMachineEvent = expectLastStateMachineEvent( - sendStateMachineEventSpy, - TaskEvent.INVITE_FAILED - ); - expect(stateMachineEvent?.reason).toBe(payload.data.reason); - sendStateMachineEventSpy.mockRestore(); + task.updateTaskData(payload.data); + webSocketManagerMock.emit('message', JSON.stringify(payload)); + const stateMachineEvent = expectLastStateMachineEvent( + sendStateMachineEventSpy, + TaskEvent.INVITE_FAILED + ); + expect(stateMachineEvent?.reason).toBe(payload.data.reason); + sendStateMachineEventSpy.mockRestore(); }); - it('should emit TASK_HYDRATE even if task is already present in taskManager', () => { const payload = { data: { @@ -688,7 +753,7 @@ describe('TaskManager', () => { const testAgentId = '723a8ffb-a26e-496d-b14a-ff44fb83b64f'; taskManager.setAgentId(testAgentId); taskManager.taskCollection = []; - + const payload = { data: { ...initalPayload.data, @@ -697,9 +762,9 @@ describe('TaskManager', () => { mediaType: 'telephony', state: 'conference', participants: { - [testAgentId]: { pType: 'Agent', hasLeft: false }, - 'agent-2': { pType: 'Agent', hasLeft: false }, - 'customer-1': { pType: 'Customer', hasLeft: false }, + [testAgentId]: {pType: 'Agent', hasLeft: false}, + 'agent-2': {pType: 'Agent', hasLeft: false}, + 'customer-1': {pType: 'Customer', hasLeft: false}, }, media: { [taskId]: { @@ -722,7 +787,7 @@ describe('TaskManager', () => { const testAgentId = '723a8ffb-a26e-496d-b14a-ff44fb83b64f'; taskManager.setAgentId(testAgentId); taskManager.taskCollection = []; - + const payload = { data: { ...initalPayload.data, @@ -731,8 +796,8 @@ describe('TaskManager', () => { mediaType: 'telephony', state: 'connected', participants: { - [testAgentId]: { pType: 'Agent', hasLeft: false }, - 'customer-1': { pType: 'Customer', hasLeft: false }, + [testAgentId]: {pType: 'Agent', hasLeft: false}, + 'customer-1': {pType: 'Customer', hasLeft: false}, }, media: { [taskId]: { @@ -768,7 +833,7 @@ describe('TaskManager', () => { destAgentId: 'ebeb893b-ba67-4f36-8418-95c7492b28c2', owner: '723a8ffb-a26e-496d-b14a-ff44fb83b64f', queueMgr: 'aqm', - wrapUpRequired: true + wrapUpRequired: true, }, }; @@ -947,7 +1012,9 @@ describe('TaskManager', () => { const task = taskManager.getTask(taskId); const taskEmitSpy = jest.spyOn(task, 'emit'); - const taskAcceptSpy = jest.spyOn(task, 'accept').mockRejectedValue(new Error('Accept failed')); + const taskAcceptSpy = jest + .spyOn(task, 'accept') + .mockRejectedValue(new Error('Accept failed')); // Step 2: Trigger AGENT_OFFER_CONTACT with auto-answer (will fail) const autoAnswerPayload = { @@ -1017,7 +1084,7 @@ describe('TaskManager', () => { }); // Verify task auto-answer event was emitted expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_AUTO_ANSWERED, task); - + // Verify isConsulted flag is set correctly expect(task.data.isConsulted).toBe(true); sendStateMachineEventSpy.mockRestore(); @@ -1054,7 +1121,10 @@ describe('TaskManager', () => { expect(taskAcceptSpy).not.toHaveBeenCalled(); // Verify TASK_AUTO_ANSWERED event was NOT emitted - expect(taskEmitSpy).not.toHaveBeenCalledWith(TASK_EVENTS.TASK_AUTO_ANSWERED, expect.anything()); + expect(taskEmitSpy).not.toHaveBeenCalledWith( + TASK_EVENTS.TASK_AUTO_ANSWERED, + expect.anything() + ); }); }); @@ -1498,10 +1568,7 @@ describe('TaskManager', () => { webSocketManagerMock.emit('message', JSON.stringify(ronaPayload)); - const stateMachineEvent = expectLastStateMachineEvent( - sendStateMachineEventSpy, - TaskEvent.RONA - ); + const stateMachineEvent = expectLastStateMachineEvent(sendStateMachineEventSpy, TaskEvent.RONA); expect(stateMachineEvent?.reason).toBe(ronaPayload.data.reason); sendStateMachineEventSpy.mockRestore(); }); @@ -1667,10 +1734,10 @@ describe('TaskManager', () => { const chatPayload = { data: { ...initalPayload.data, - interaction: { mediaType: 'chat' }, + interaction: {mediaType: 'chat'}, }, }; - + // Simulate receiving a chat task webSocketManagerMock.emit('message', JSON.stringify(chatPayload)); @@ -1689,10 +1756,10 @@ describe('TaskManager', () => { const emailPayload = { data: { ...initalPayload.data, - interaction: { mediaType: 'email' }, + interaction: {mediaType: 'email'}, }, }; - + // Simulate receiving an email task webSocketManagerMock.emit('message', JSON.stringify(emailPayload)); @@ -1712,16 +1779,16 @@ describe('TaskManager', () => { data: { ...initalPayload.data, type: CC_EVENTS.AGENT_CONTACT_RESERVED, - interaction: { mediaType: 'chat' }, + interaction: {mediaType: 'chat'}, }, }; - + webSocketManagerMock.emit('message', JSON.stringify(chatReservedPayload)); const task = taskManager.getTask(chatReservedPayload.data.interactionId); const sendStateMachineEventSpy = task.sendStateMachineEvent as jest.Mock; - + expectLastStateMachineEvent(sendStateMachineEventSpy, TaskEvent.TASK_INCOMING); - + // 2. Chat task is assigned const chatAssignedPayload = { data: { @@ -1729,17 +1796,17 @@ describe('TaskManager', () => { type: CC_EVENTS.AGENT_CONTACT_ASSIGNED, }, }; - + webSocketManagerMock.emit('message', JSON.stringify(chatAssignedPayload)); - + expectLastStateMachineEvent(sendStateMachineEventSpy, TaskEvent.ASSIGN); - + // 3. Chat task is ended with state 'new' to trigger cleanup const chatEndedPayload = { data: { ...chatReservedPayload.data, type: CC_EVENTS.CONTACT_ENDED, - interaction: { mediaType: 'chat', state: 'new' }, // Change to 'new' state + interaction: {mediaType: 'chat', state: 'new'}, // Change to 'new' state wrapUpRequired: false, }, }; @@ -1759,40 +1826,46 @@ describe('TaskManager', () => { data: { ...initalPayload.data, interactionId: 'telephony-task-id', - interaction: { mediaType: 'telephony' }, + interaction: {mediaType: 'telephony'}, }, }; - + const chatPayload = { data: { ...initalPayload.data, interactionId: 'chat-task-id', - interaction: { mediaType: 'chat' }, + interaction: {mediaType: 'chat'}, }, }; - + const emailPayload = { data: { ...initalPayload.data, interactionId: 'email-task-id', - interaction: { mediaType: 'email' }, + interaction: {mediaType: 'email'}, }, }; - + // Simulate receiving tasks of different types webSocketManagerMock.emit('message', JSON.stringify(telephonyPayload)); webSocketManagerMock.emit('message', JSON.stringify(chatPayload)); webSocketManagerMock.emit('message', JSON.stringify(emailPayload)); - + // Verify all tasks are in the collection expect(taskManager.getAllTasks()).toHaveProperty(telephonyPayload.data.interactionId); expect(taskManager.getAllTasks()).toHaveProperty(chatPayload.data.interactionId); expect(taskManager.getAllTasks()).toHaveProperty(emailPayload.data.interactionId); - + // Verify the task media types are correctly set - expect(taskManager.getTask(telephonyPayload.data.interactionId).data.interaction.mediaType).toBe('telephony'); - expect(taskManager.getTask(chatPayload.data.interactionId).data.interaction.mediaType).toBe('chat'); - expect(taskManager.getTask(emailPayload.data.interactionId).data.interaction.mediaType).toBe('email'); + expect( + taskManager.getTask(telephonyPayload.data.interactionId).data.interaction.mediaType + ).toBe('telephony'); + expect(taskManager.getTask(chatPayload.data.interactionId).data.interaction.mediaType).toBe( + 'chat' + ); + expect(taskManager.getTask(emailPayload.data.interactionId).data.interaction.mediaType).toBe( + 'email' + ); }); it('should properly handle one task ending when multiple tasks are active', () => { @@ -1801,91 +1874,91 @@ describe('TaskManager', () => { data: { ...initalPayload.data, interactionId: 'task-id-1', - interaction: { mediaType: 'telephony' }, + interaction: {mediaType: 'telephony'}, }, }; - + const task2Payload = { data: { ...initalPayload.data, interactionId: 'task-id-2', - interaction: { mediaType: 'chat' }, + interaction: {mediaType: 'chat'}, }, }; - + const task3Payload = { data: { ...initalPayload.data, interactionId: 'task-id-3', - interaction: { mediaType: 'email' }, + interaction: {mediaType: 'email'}, }, }; - + // Initialize all tasks webSocketManagerMock.emit('message', JSON.stringify(task1Payload)); webSocketManagerMock.emit('message', JSON.stringify(task2Payload)); webSocketManagerMock.emit('message', JSON.stringify(task3Payload)); - + // Verify all tasks are in the collection expect(taskManager.getAllTasks()).toHaveProperty(task1Payload.data.interactionId); expect(taskManager.getAllTasks()).toHaveProperty(task2Payload.data.interactionId); expect(taskManager.getAllTasks()).toHaveProperty(task3Payload.data.interactionId); - + const task2 = taskManager.getTask(task2Payload.data.interactionId); const task2SendStateMachineEventSpy = task2.sendStateMachineEvent as jest.Mock; - + // End only the second task (chat task) const chatEndedPayload = { data: { ...task2Payload.data, type: CC_EVENTS.CONTACT_ENDED, - interaction: { mediaType: 'chat', state: 'new' }, // Using 'new' to trigger cleanup + interaction: {mediaType: 'chat', state: 'new'}, // Using 'new' to trigger cleanup wrapUpRequired: false, }, }; - + task2.data.interaction.state = 'new'; webSocketManagerMock.emit('message', JSON.stringify(chatEndedPayload)); - + const firstEndEvent = expectLastStateMachineEvent( task2SendStateMachineEventSpy, TaskEvent.CONTACT_ENDED ); expect(firstEndEvent?.taskData).toEqual(chatEndedPayload.data); - + // Verify task2 was removed from collection (since state was 'new') expect(taskManager.getTask(task2Payload.data.interactionId)).toBeUndefined(); - + // Verify other tasks remain in the collection expect(taskManager.getTask(task1Payload.data.interactionId)).toBeDefined(); expect(taskManager.getTask(task3Payload.data.interactionId)).toBeDefined(); - + // Store reference to task3 before we end it const task3 = taskManager.getTask(task3Payload.data.interactionId); const task3SendStateMachineEventSpy = task3.sendStateMachineEvent as jest.Mock; - + // Now end task3 with a state that doesn't trigger cleanup const emailEndedPayload = { data: { ...task3Payload.data, type: CC_EVENTS.CONTACT_ENDED, - interaction: { mediaType: 'email', state: 'connected' }, // Using 'connected' to NOT trigger cleanup + interaction: {mediaType: 'email', state: 'connected'}, // Using 'connected' to NOT trigger cleanup wrapUpRequired: true, }, }; - + task3.data.interaction.state = 'connected'; webSocketManagerMock.emit('message', JSON.stringify(emailEndedPayload)); - + const secondEndEvent = expectLastStateMachineEvent( task3SendStateMachineEventSpy, TaskEvent.CONTACT_ENDED ); expect(secondEndEvent?.taskData).toEqual(emailEndedPayload.data); - + // Verify task3 is still in collection (since state was 'connected') expect(taskManager.getTask(task3Payload.data.interactionId)).toBeDefined(); - + // Verify task1 remains unaffected expect(taskManager.getTask(task1Payload.data.interactionId)).toBeDefined(); }); @@ -1895,7 +1968,7 @@ describe('TaskManager', () => { webSocketManagerMock.emit('message', JSON.stringify(initalPayload)); const task = taskManager.getTask(taskId); const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent'); - + const vteamTransferredPayload = { data: { type: CC_EVENTS.AGENT_VTEAM_TRANSFERRED, @@ -1912,16 +1985,16 @@ describe('TaskManager', () => { queueMgr: initalPayload.data.queueMgr, }, }; - + // No need to explicitly set the task in the collection as it's already there // from the initial message processing - + webSocketManagerMock.emit('message', JSON.stringify(vteamTransferredPayload)); - + // Check that the state machine received the END event expectLastStateMachineEvent(sendStateMachineEventSpy, TaskEvent.TRANSFER_SUCCESS); sendStateMachineEventSpy.mockRestore(); - + // The task should still exist in the collection based on current implementation expect(taskManager.getTask(taskId)).toBeDefined(); }); @@ -1956,7 +2029,7 @@ describe('TaskManager', () => { data: { type: CC_EVENTS.AGENT_CONTACT_UNASSIGNED, agentId: initalPayload.data.agentId, - interaction: { mediaType: 'telephony' }, + interaction: {mediaType: 'telephony'}, interactionId: initalPayload.data.interactionId, orgId: initalPayload.data.orgId, trackingId: initalPayload.data.trackingId, @@ -1975,7 +2048,7 @@ describe('TaskManager', () => { data: { type: CC_EVENTS.AGENT_WRAPUP, interactionId: taskId, - interaction: { mediaType: 'telephony' }, + interaction: {mediaType: 'telephony'}, }, }; webSocketManagerMock.emit('message', JSON.stringify(wrapupPayload)); @@ -1992,7 +2065,7 @@ describe('TaskManager', () => { data: { type: CC_EVENTS.AGENT_VTEAM_TRANSFERRED, agentId: initalPayload.data.agentId, - interaction: { mediaType: 'telephony' }, + interaction: {mediaType: 'telephony'}, interactionId: initalPayload.data.interactionId, orgId: initalPayload.data.orgId, trackingId: initalPayload.data.trackingId, @@ -2011,7 +2084,7 @@ describe('TaskManager', () => { data: { type: CC_EVENTS.AGENT_WRAPUP, interactionId: taskId, - interaction: { mediaType: 'telephony' }, + interaction: {mediaType: 'telephony'}, }, }; webSocketManagerMock.emit('message', JSON.stringify(wrapupPayload)); @@ -2031,9 +2104,9 @@ describe('TaskManager', () => { ['STARTED', 'PAUSED', 'PAUSE_FAILED', 'RESUMED', 'RESUME_FAILED'].forEach((suffix) => { const ccEvent = CC_EVENTS[`CONTACT_RECORDING_${suffix}`]; const expectedTaskEvent = eventMap[suffix]; - it(`should ${ - expectedTaskEvent ? 'send' : 'not send' - } ${expectedTaskEvent ?? 'a'} state machine event on ${ccEvent} event`, () => { + it(`should ${expectedTaskEvent ? 'send' : 'not send'} ${ + expectedTaskEvent ?? 'a' + } state machine event on ${ccEvent} event`, () => { const payload = {data: {...initalPayload.data, type: ccEvent}}; webSocketManagerMock.emit('message', JSON.stringify(initalPayload)); const task = taskManager.getTask(taskId); @@ -2052,7 +2125,7 @@ describe('TaskManager', () => { } }); }); - }); + }); describe('Conference event handling', () => { let task; From 49f49969276c6d63fd5cabac97315c3d198d2c6b Mon Sep 17 00:00:00 2001 From: Priya Date: Thu, 19 Mar 2026 00:46:22 +0530 Subject: [PATCH 03/10] feat(contact-center): event emissions fixes --- .../src/services/task/TaskManager.ts | 43 +++++++++++++------ .../contact-center/src/services/task/types.ts | 25 +++++++++++ packages/@webex/contact-center/src/types.ts | 26 +++++++---- .../unit/spec/services/task/TaskManager.ts | 35 ++++++++++++++- 4 files changed, 105 insertions(+), 24 deletions(-) diff --git a/packages/@webex/contact-center/src/services/task/TaskManager.ts b/packages/@webex/contact-center/src/services/task/TaskManager.ts index 786f5ffaa9b..d5aa23f6c37 100644 --- a/packages/@webex/contact-center/src/services/task/TaskManager.ts +++ b/packages/@webex/contact-center/src/services/task/TaskManager.ts @@ -17,7 +17,13 @@ import { import {TASK_MANAGER_FILE} from '../../constants'; import {METHODS} from './constants'; import {CC_EVENTS, WrapupData} from '../config/types'; -import {ConfigFlags, LoginOption, TranscriptAction} from '../../types'; +import { + ConfigFlags, + LoginOption, + TranscriptAction, + AIAssistantEventType, + AIAssistantEventName, +} from '../../types'; import LoggerProxy from '../../logger-proxy'; import {getIsConferenceInProgress, isSecondaryEpDnAgent, shouldAutoAnswerTask} from './TaskUtils'; import TaskFactory from './TaskFactory'; @@ -327,13 +333,20 @@ export default class TaskManager extends EventEmitter { this.webSocketManager.on('message', (event) => { // Step 1: Parse and validate the message const message = TaskManager.parseWebSocketMessage(event); + // if (message.type === CC_EVENTS.REAL_TIME_TRANSCRIPTION) { + // task.emit(CC_EVENTS.REAL_TIME_TRANSCRIPTION, message.data); + // } if (!message) return; // Step 2: Prepare event context const eventContext = this.prepareEventContext(message); if (!eventContext) return; - // Step 3: Handle event lifecycle and get actions to perform + 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; @@ -341,14 +354,6 @@ export default class TaskManager extends EventEmitter { const {payload, stateMachineEvent} = eventContext; - if (eventContext.eventType === CC_EVENTS.REAL_TIME_TRANSCRIPTION) { - task.emit(CC_EVENTS.REAL_TIME_TRANSCRIPTION, payload); - // Backward-compatible alias consumed by existing sample apps. - task.emit(CC_EVENTS.REAL_TIME_TRANSCRIPTION, payload); - - return; - } - // Always keep task.data updated (even for mapped events) so consumers relying // on TaskManager-managed task instances see the latest payload. if (payload) { @@ -401,13 +406,16 @@ export default class TaskManager extends EventEmitter { * @returns Event context or null if event type is invalid */ private prepareEventContext(message: WebSocketMessage): EventContext | null { - const eventType = message.data?.type; + const eventType = message.data?.type || message.type; if (!eventType || !isCcEvent(eventType)) { return null; } - const interactionId = message.data.interactionId; + const interactionId = + eventType === CC_EVENTS.REAL_TIME_TRANSCRIPTION + ? message.data.data.conversationId + : message.data.interactionId; const task = this.taskCollection[interactionId]; const wasConsultedTask = Boolean(task?.data?.isConsulted); @@ -431,6 +439,7 @@ export default class TaskManager extends EventEmitter { wrapUpRequired: computeWrapUpRequired(), } : message.data; + const stateMachineEvent = TaskManager.mapEventToTaskStateMachineEvent( eventType, adjustedPayload, @@ -440,7 +449,7 @@ export default class TaskManager extends EventEmitter { LoggerProxy.info(`Handling task event ${eventType}`, { module: TASK_MANAGER_FILE, method: 'prepareEventContext', - interactionId: message.data?.interactionId, + interactionId, }); return { @@ -711,7 +720,13 @@ export default class TaskManager extends EventEmitter { if (!action || !this.apiAIAssistant) return; this.apiAIAssistant - .sendEvent(this.agentId, interactionId, 'CUSTOM_EVENT', 'GET_TRANSCRIPTS', action) + .sendEvent( + this.agentId, + interactionId, + AIAssistantEventType.CUSTOM_EVENT, + AIAssistantEventName.GET_TRANSCRIPTS, + action + ) .catch((error) => { LoggerProxy.error(`Failed to send transcript ${action} event`, { module: TASK_MANAGER_FILE, diff --git a/packages/@webex/contact-center/src/services/task/types.ts b/packages/@webex/contact-center/src/services/task/types.ts index 5de02d9bc48..a1c9334fe3e 100644 --- a/packages/@webex/contact-center/src/services/task/types.ts +++ b/packages/@webex/contact-center/src/services/task/types.ts @@ -963,6 +963,26 @@ export type Interaction = { * for UI/state machine updates. * @public */ +export type RealtimeTranscription = { + agentId: string; + orgId: string; + notifType: string; + notifDetails: { + actionEvent: string; + }; + data: { + role: 'AGENT' | 'CALLER'; + utteranceId: string; + conversationId: string; + publishTimestamp: number; + messageId: string; + isFinal: boolean; + languageCode: string; + orgId: string; + content: string; + }; +}; + export type TaskData = { /** Primary media resource identifier for the active leg (matches interaction.media[].mediaResourceId) */ mediaResourceId: string; @@ -1869,6 +1889,11 @@ export type WebSocketPayload = TaskData & { type: string; mediaResourceId?: string; reason?: string; + /** + * Optional real-time transcript chunk payload. + * Present on REAL_TIME_TRANSCRIPTION notifications. + */ + data?: RealtimeTranscription['data']; }; export type WebSocketMessage = { diff --git a/packages/@webex/contact-center/src/types.ts b/packages/@webex/contact-center/src/types.ts index 8667d8843eb..bd2fadb2b5e 100644 --- a/packages/@webex/contact-center/src/types.ts +++ b/packages/@webex/contact-center/src/types.ts @@ -838,15 +838,23 @@ export type UpdateDeviceTypeResponse = Agent.DeviceTypeUpdateSuccess | Error; export type TranscriptAction = 'START' | 'STOP'; -export type AIAssistantEventType = 'CUSTOM_EVENT' | 'CTI_EVENT'; - -export type AIAssistantEventName = - | 'GET_TRANSCRIPTS' - | 'GET_MID_CALL_SUMMARY' - | 'GET_POST_CALL_SUMMARY' - | 'MID_CALL_SUMMARY_RESPONSE' - | 'POST_CALL_SUMMARY_RESPONSE' - | 'SUGGESTED_RESPONSES_DIGITAL'; +export const AIAssistantEventType = { + CUSTOM_EVENT: 'CUSTOM_EVENT', + CTI_EVENT: 'CTI_EVENT', +} as const; + +export type AIAssistantEventType = Enum; + +export const AIAssistantEventName = { + GET_TRANSCRIPTS: 'GET_TRANSCRIPTS', + GET_MID_CALL_SUMMARY: 'GET_MID_CALL_SUMMARY', + GET_POST_CALL_SUMMARY: 'GET_POST_CALL_SUMMARY', + MID_CALL_SUMMARY_RESPONSE: 'MID_CALL_SUMMARY_RESPONSE', + POST_CALL_SUMMARY_RESPONSE: 'POST_CALL_SUMMARY_RESPONSE', + SUGGESTED_RESPONSES_DIGITAL: 'SUGGESTED_RESPONSES_DIGITAL', +} as const; + +export type AIAssistantEventName = Enum; export type TranscriptMessage = { role: string; 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 793a8624b04..006691f59f2 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 @@ -303,7 +303,40 @@ describe('TaskManager', () => { CC_EVENTS.REAL_TIME_TRANSCRIPTION, realtimePayload.data ); - expect(taskEmitSpy).toHaveBeenCalledWith('realtimeTranscription', realtimePayload.data); + }); + + it('should emit REAL_TIME_TRANSCRIPTION when eventType is in top-level payload', () => { + const task = taskManager.getTask(taskId); + const taskEmitSpy = jest.spyOn(task, 'emit'); + const realtimePayload = { + data: { + agentId: 'test-agent-id', + data: { + content: 'Thank you. Okay.', + conversationId: taskId, + 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', + }; + + webSocketManagerMock.emit('message', JSON.stringify(realtimePayload)); + + expect(taskEmitSpy).toHaveBeenCalledWith(CC_EVENTS.REAL_TIME_TRANSCRIPTION, realtimePayload.data); }); it('should not re-emit agent related events', () => { From 731461b0ce9dc9e68e0bdbb16afa7badf76c052d Mon Sep 17 00:00:00 2001 From: Priya Date: Mon, 23 Mar 2026 09:27:43 +0530 Subject: [PATCH 04/10] feat(contact-center): fixed unit tests, did some refinements --- docs/samples/contact-center/app.js | 105 ++++++++++++++-- docs/samples/contact-center/index.html | 118 +++++++++++++++++- .../src/services/core/WebexRequest.ts | 14 --- .../src/services/task/TaskManager.ts | 18 +-- .../src/services/task/constants.ts | 11 ++ packages/@webex/contact-center/src/types.ts | 67 ++++++++++ .../contact-center/test/unit/spec/cc.ts | 20 ++- .../test/unit/spec/services/ApiAiAssistant.ts | 2 +- .../test/unit/spec/services/config/index.ts | 116 ++++++++++------- .../unit/spec/services/task/TaskManager.ts | 32 ++--- 10 files changed, 374 insertions(+), 129 deletions(-) diff --git a/docs/samples/contact-center/app.js b/docs/samples/contact-center/app.js index cb79f5e63d2..45cd4a37bc3 100644 --- a/docs/samples/contact-center/app.js +++ b/docs/samples/contact-center/app.js @@ -91,29 +91,117 @@ const timerValueElm = autoWrapupTimerElm.querySelector('.timer-value'); const outdialAniSelectElm = document.querySelector('#outdialAniSelect'); const realtimeTranscriptsElm = document.querySelector('#realtime-transcripts-content'); const clearTranscriptsButton = document.querySelector('#clear-transcripts'); +const liveTranscriptTabElm = document.querySelector('#transcript-tab-live'); +const ivrTranscriptTabElm = document.querySelector('#transcript-tab-ivr'); +const liveTranscriptPaneElm = document.querySelector('#transcript-live-pane'); +const ivrTranscriptPaneElm = document.querySelector('#transcript-ivr-pane'); deregisterBtn.style.backgroundColor = 'red'; -const transcriptLines = []; +const transcriptEntries = []; const MAX_TRANSCRIPT_LINES = 200; +function formatTranscriptTime(epochMillis) { + if (!epochMillis || typeof epochMillis !== 'number') { + return '--:--'; + } + return new Date(epochMillis).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'}); +} + +function setTranscriptTab(tab) { + if (!liveTranscriptTabElm || !ivrTranscriptTabElm || !liveTranscriptPaneElm || !ivrTranscriptPaneElm) { + return; + } + + const isLive = tab === 'live'; + liveTranscriptTabElm.classList.toggle('active', isLive); + ivrTranscriptTabElm.classList.toggle('active', !isLive); + liveTranscriptTabElm.setAttribute('aria-selected', isLive ? 'true' : 'false'); + ivrTranscriptTabElm.setAttribute('aria-selected', !isLive ? 'true' : 'false'); + liveTranscriptPaneElm.classList.toggle('hidden', !isLive); + ivrTranscriptPaneElm.classList.toggle('hidden', isLive); +} + +function renderRealtimeTranscripts() { + if (!realtimeTranscriptsElm) { + return; + } + + realtimeTranscriptsElm.innerHTML = ''; + if (!transcriptEntries.length) { + const emptyElm = document.createElement('div'); + emptyElm.className = 'transcript-empty'; + emptyElm.textContent = 'No live transcript received.'; + realtimeTranscriptsElm.appendChild(emptyElm); + return; + } + + const fragment = document.createDocumentFragment(); + transcriptEntries.forEach((entry) => { + const row = document.createElement('div'); + row.className = 'transcript-item'; + + const avatar = document.createElement('div'); + avatar.className = 'transcript-avatar'; + avatar.textContent = entry.role === 'AGENT' ? 'AG' : 'CU'; + + const body = document.createElement('div'); + + const meta = document.createElement('div'); + meta.className = 'transcript-meta'; + meta.textContent = entry.role === 'AGENT' ? '%You%' : '%Customer%'; + + const time = document.createElement('span'); + time.className = 'transcript-time'; + time.textContent = formatTranscriptTime(entry.publishTimestamp); + meta.appendChild(time); + + const content = document.createElement('div'); + content.className = 'transcript-content'; + content.textContent = entry.content; + + body.appendChild(meta); + body.appendChild(content); + row.appendChild(avatar); + row.appendChild(body); + fragment.appendChild(row); + }); + + realtimeTranscriptsElm.appendChild(fragment); + realtimeTranscriptsElm.parentElement.scrollTop = realtimeTranscriptsElm.parentElement.scrollHeight; +} + function appendRealtimeTranscript(payload) { - const transcriptContent = payload?.data?.data?.content || payload?.data?.content; + const dataNode = payload?.data; + const transcriptNode = dataNode?.data || dataNode; + const transcriptContent = transcriptNode?.content; if (!transcriptContent || typeof transcriptContent !== 'string') { return; } - transcriptLines.push(transcriptContent.trim()); - if (transcriptLines.length > MAX_TRANSCRIPT_LINES) { - transcriptLines.shift(); + transcriptEntries.push({ + role: transcriptNode?.role || 'CALLER', + publishTimestamp: transcriptNode?.publishTimestamp || Date.now(), + content: transcriptContent.trim(), + }); + if (transcriptEntries.length > MAX_TRANSCRIPT_LINES) { + transcriptEntries.shift(); } - realtimeTranscriptsElm.textContent = transcriptLines.join('\n'); + renderRealtimeTranscripts(); + setTranscriptTab('live'); +} + +if (liveTranscriptTabElm) { + liveTranscriptTabElm.addEventListener('click', () => setTranscriptTab('live')); +} +if (ivrTranscriptTabElm) { + ivrTranscriptTabElm.addEventListener('click', () => setTranscriptTab('ivr')); } if (clearTranscriptsButton) { clearTranscriptsButton.addEventListener('click', () => { - transcriptLines.length = 0; - realtimeTranscriptsElm.textContent = 'No transcripts received'; + transcriptEntries.length = 0; + renderRealtimeTranscripts(); }); } @@ -1191,6 +1279,7 @@ function isInteractionOnHold(task) { // Register task listeners function registerTaskListeners(task) { task.on('REAL_TIME_TRANSCRIPTION', (payload) => { + console.info('Received real-time transcription:', payload); appendRealtimeTranscript(payload); }); diff --git a/docs/samples/contact-center/index.html b/docs/samples/contact-center/index.html index b6f3f351257..66a0cf4d53b 100644 --- a/docs/samples/contact-center/index.html +++ b/docs/samples/contact-center/index.html @@ -307,12 +307,26 @@

- Real-time Transcripts -
- Transcript chunks received from realtime transcription events - + Conversation +
+
+ + + +
+ +
+
+
No live transcript received.
+
+
-
No transcripts received
@@ -361,6 +375,100 @@

Outdial Failed

.hidden { display: none; } + + .transcript-card { + background: #fff; + border: 1px solid #e6e6e6; + border-radius: 8px; + padding: 12px; + } + + .transcript-tabs { + align-items: center; + border-bottom: 1px solid #ddd; + display: flex; + gap: 8px; + margin-bottom: 10px; + padding-bottom: 8px; + } + + .transcript-tab { + background: transparent; + border: 0; + color: #5a5a5a; + cursor: pointer; + font-weight: 600; + padding: 4px 8px; + } + + .transcript-tab.active { + border-bottom: 2px solid #0f172a; + color: #111827; + } + + .transcript-clear-btn { + margin-left: auto; + padding: 3px 10px; + } + + .transcript-pane { + background: #f7f7f8; + border-radius: 8px; + height: 380px; + overflow-y: auto; + padding: 12px; + } + + .transcript-list { + display: flex; + flex-direction: column; + gap: 12px; + } + + .transcript-item { + display: flex; + gap: 10px; + } + + .transcript-avatar { + align-items: center; + background: #e5e7eb; + border-radius: 50%; + color: #111827; + display: flex; + flex: 0 0 34px; + font-size: 12px; + font-weight: 700; + height: 34px; + justify-content: center; + margin-top: 2px; + width: 34px; + } + + .transcript-meta { + color: #4b5563; + font-size: 12px; + margin-bottom: 2px; + } + + .transcript-time { + color: #2563eb; + margin-left: 8px; + text-decoration: underline; + } + + .transcript-content { + color: #111827; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; + } + + .transcript-empty { + color: #6b7280; + font-size: 13px; + padding: 4px 0; + } diff --git a/docs/samples/contact-center/style.css b/docs/samples/contact-center/style.css index 236ed9f47f6..ea95ab41fe6 100644 --- a/docs/samples/contact-center/style.css +++ b/docs/samples/contact-center/style.css @@ -539,4 +539,121 @@ legend { background-color: #e6f7ff; border-left: 3px solid #1890ff; font-weight: bold; -} \ No newline at end of file +} + +.modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; +} + +.modal-content { + background-color: white; + padding: 20px; + border-radius: 5px; + text-align: center; +} + +.hidden { + display: none; +} + +.transcript-card { + background: #fff; + border: 1px solid #e6e6e6; + border-radius: 8px; + padding: 12px; +} + +.transcript-tabs { + align-items: center; + border-bottom: 1px solid #ddd; + display: flex; + gap: 8px; + margin-bottom: 10px; + padding-bottom: 8px; +} + +.transcript-tab { + background: transparent; + border: 0; + color: #5a5a5a; + cursor: pointer; + font-weight: 600; + padding: 4px 8px; +} + +.transcript-tab.active { + border-bottom: 2px solid #0f172a; + color: #111827; +} + +.transcript-clear-btn { + margin-left: auto; + padding: 3px 10px; +} + +.transcript-pane { + background: #f7f7f8; + border-radius: 8px; + height: 380px; + overflow-y: auto; + padding: 12px; +} + +.transcript-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.transcript-item { + display: flex; + gap: 10px; +} + +.transcript-avatar { + align-items: center; + background: #e5e7eb; + border-radius: 50%; + color: #111827; + display: flex; + flex: 0 0 34px; + font-size: 12px; + font-weight: 700; + height: 34px; + justify-content: center; + margin-top: 2px; + width: 34px; +} + +.transcript-meta { + color: #4b5563; + font-size: 12px; + margin-bottom: 2px; +} + +.transcript-time { + color: #2563eb; + margin-left: 8px; + text-decoration: underline; +} + +.transcript-content { + color: #111827; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; +} + +.transcript-empty { + color: #6b7280; + font-size: 13px; + padding: 4px 0; +} diff --git a/packages/@webex/contact-center/src/cc.ts b/packages/@webex/contact-center/src/cc.ts index d9033e1d8b7..d38cf978b43 100644 --- a/packages/@webex/contact-center/src/cc.ts +++ b/packages/@webex/contact-center/src/cc.ts @@ -721,6 +721,7 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter isEndConsultEnabled: this.agentConfig.isEndConsultEnabled, webRtcEnabled: this.agentConfig.webRtcEnabled, autoWrapup: this.agentConfig.wrapUpData?.wrapUpProps?.autoWrapup ?? false, + aiFeature: this.agentConfig.aiFeature, }; this.taskManager.setConfigFlags(configFlags); // TODO: Make profile a singleton to make it available throughout app/sdk so we dont need to inject info everywhere diff --git a/packages/@webex/contact-center/src/constants.ts b/packages/@webex/contact-center/src/constants.ts index 567145ea072..10227fef727 100644 --- a/packages/@webex/contact-center/src/constants.ts +++ b/packages/@webex/contact-center/src/constants.ts @@ -61,6 +61,7 @@ export const METHODS = { TOGGLE_MUTE: 'toggleMute', COMPLETE_TRANSFER: 'completeTransfer', GET_OUTDIAL_ANI_ENTRIES: 'getOutdialAniEntries', + GET_BASE_URL: 'getBaseUrl', SEND_EVENT: 'sendEvent', FETCH_HISTORIC_TRANSCRIPTS: 'fetchHistoricTranscripts', }; diff --git a/packages/@webex/contact-center/src/services/ApiAiAssistant.ts b/packages/@webex/contact-center/src/services/ApiAiAssistant.ts index 3aeb269fdf8..b7dd6d52dc7 100644 --- a/packages/@webex/contact-center/src/services/ApiAiAssistant.ts +++ b/packages/@webex/contact-center/src/services/ApiAiAssistant.ts @@ -12,7 +12,12 @@ import { HistoricTranscriptsResponse, } from '../types'; import {getErrorDetails} from './core/Utils'; -import {WCC_API_GATEWAY} from './constants'; +import { + AI_ASSISTANT_BASE_URL_TEMPLATE, + AI_ASSISTANT_ENV_MAP, + AI_ASSISTANT_API_URLS, + WCC_API_GATEWAY, +} from './constants'; import {AIFeatureFlags} from './config/types'; /** @@ -42,37 +47,34 @@ export class ApiAIAssistant { } catch (_error) { wccApiGatewayUrl = ''; } + if (!wccApiGatewayUrl) { - throw new Error('AI_ASSISTANT_BASE_URL_NOT_AVAILABLE'); + const {error: detailedError} = getErrorDetails( + new Error('AI_ASSISTANT_BASE_URL_NOT_AVAILABLE'), + METHODS.GET_BASE_URL, + CC_FILE + ); + throw detailedError; } let hostname = ''; try { hostname = new URL(wccApiGatewayUrl).hostname.toLowerCase(); - } catch (_error) { + } catch (error) { hostname = wccApiGatewayUrl.toLowerCase(); } - const envMap: Record = { - 'api.intgus1.ciscoccservice.com': 'intgus1', - 'api.qaus1.ciscoccservice.com': 'qaus1', - 'api.wxcc-us1.cisco.com': 'produs1', - 'api.wxcc-eu1.cisco.com': 'prodeu1', - 'api.wxcc-eu2.cisco.com': 'prodeu2', - 'api.wxcc-anz1.cisco.com': 'prodanz1', - 'api.wxcc-ca1.cisco.com': 'prodca1', - 'api.wxcc-jp1.cisco.com': 'prodjp1', - 'api.wxcc-sg1.cisco.com': 'prodsg1', - 'api.wxcc-in1.cisco.com': 'prodin1', - 'api.loadus1.cisco.com': 'loadus1', - }; - - const resolvedEnv = envMap[hostname]; + const resolvedEnv = AI_ASSISTANT_ENV_MAP[hostname]; if (!resolvedEnv) { - throw new Error('AI_ASSISTANT_BASE_URL_NOT_AVAILABLE'); + const {error: detailedError} = getErrorDetails( + new Error('AI_ASSISTANT_BASE_URL_NOT_AVAILABLE'), + METHODS.GET_BASE_URL, + CC_FILE + ); + throw detailedError; } - return `https://api-ai-assistant.${resolvedEnv}.ciscoccservice.com`; + return AI_ASSISTANT_BASE_URL_TEMPLATE.replace('%s', resolvedEnv); } /** @@ -104,7 +106,7 @@ export class ApiAIAssistant { try { const baseUrl = this.getBaseUrl(); const response = (await this.webex.request({ - uri: `${baseUrl}/event`, + uri: `${baseUrl}${AI_ASSISTANT_API_URLS.EVENT}`, method: HTTP_METHODS.POST, addAuthHeader: true, body: { @@ -141,6 +143,7 @@ export class ApiAIAssistant { }, ['operational'] ); + const {error: detailedError} = getErrorDetails(error, METHODS.SEND_EVENT, CC_FILE); throw detailedError; } @@ -165,15 +168,19 @@ export class ApiAIAssistant { METRIC_EVENT_NAMES.AI_ASSISTANT_FETCH_HISTORIC_TRANSCRIPTS_SUCCESS, METRIC_EVENT_NAMES.AI_ASSISTANT_FETCH_HISTORIC_TRANSCRIPTS_FAILED, ]); + if (!this.aiFeature?.realtimeTranscripts?.enable) { + const {error: detailedError} = getErrorDetails( + new Error('REAL_TIME_TRANSCRIPTION_NOT_ENABLED'), + METHODS.FETCH_HISTORIC_TRANSCRIPTS, + CC_FILE + ); + throw detailedError; + } try { - if (!this.aiFeature?.realtimeTranscripts?.enable) { - throw new Error('REAL_TIME_TRANSCRIPTION_NOT_ENABLED'); - } - const baseUrl = this.getBaseUrl(); const response = (await this.webex.request({ - uri: `${baseUrl}/transcripts/list`, + uri: `${baseUrl}${AI_ASSISTANT_API_URLS.TRANSCRIPTS_LIST}`, method: HTTP_METHODS.POST, addAuthHeader: true, body: { @@ -199,6 +206,9 @@ export class ApiAIAssistant { }, ['operational'] ); + if (error instanceof Error && error.message === AI_ASSISTANT_ERRORS.BASE_URL_NOT_AVAILABLE) { + throw error; + } const {error: detailedError} = getErrorDetails( error, METHODS.FETCH_HISTORIC_TRANSCRIPTS, diff --git a/packages/@webex/contact-center/src/services/config/Util.ts b/packages/@webex/contact-center/src/services/config/Util.ts index 89ba89a6639..0e47ac3e667 100644 --- a/packages/@webex/contact-center/src/services/config/Util.ts +++ b/packages/@webex/contact-center/src/services/config/Util.ts @@ -185,7 +185,7 @@ function parseAgentConfigs(profileData: { const defaultWrapUpData = getDefaultWrapUpCode(wrapupCodes); const aiFeature: AIFeatureFlags | undefined = - aiFeatureFlags?.data && aiFeatureFlags.data.length > 0 ? aiFeatureFlags.data[0] : undefined; + aiFeatureFlags?.data?.length > 0 ? aiFeatureFlags.data[0] : undefined; const finalData = { teams: teamData, diff --git a/packages/@webex/contact-center/src/services/constants.ts b/packages/@webex/contact-center/src/services/constants.ts index 19f650ea726..f27cdcb2e35 100644 --- a/packages/@webex/contact-center/src/services/constants.ts +++ b/packages/@webex/contact-center/src/services/constants.ts @@ -109,3 +109,24 @@ export const METHODS = { MAP_CALL_TO_TASK: 'mapCallToTask', GET_TASK_ID_FOR_CALL: 'getTaskIdForCall', }; + +export const AI_ASSISTANT_API_URLS = { + EVENT: '/event', + TRANSCRIPTS_LIST: '/transcripts/list', +}; + +export const AI_ASSISTANT_BASE_URL_TEMPLATE = 'https://api-ai-assistant.%s.ciscoccservice.com'; + +export const AI_ASSISTANT_ENV_MAP: Record = { + 'api.intgus1.ciscoccservice.com': 'intgus1', + 'api.qaus1.ciscoccservice.com': 'qaus1', + 'api.wxcc-us1.cisco.com': 'produs1', + 'api.wxcc-eu1.cisco.com': 'prodeu1', + 'api.wxcc-eu2.cisco.com': 'prodeu2', + 'api.wxcc-anz1.cisco.com': 'prodanz1', + 'api.wxcc-ca1.cisco.com': 'prodca1', + 'api.wxcc-jp1.cisco.com': 'prodjp1', + 'api.wxcc-sg1.cisco.com': 'prodsg1', + 'api.wxcc-in1.cisco.com': 'prodin1', + 'api.loadus1.cisco.com': 'loadus1', +}; diff --git a/packages/@webex/contact-center/src/services/task/TaskManager.ts b/packages/@webex/contact-center/src/services/task/TaskManager.ts index cf9323e48f6..f7d86d6c55f 100644 --- a/packages/@webex/contact-center/src/services/task/TaskManager.ts +++ b/packages/@webex/contact-center/src/services/task/TaskManager.ts @@ -319,9 +319,6 @@ export default class TaskManager extends EventEmitter { this.webSocketManager.on('message', (event) => { // Step 1: Parse and validate the message const message = TaskManager.parseWebSocketMessage(event); - // if (message.type === CC_EVENTS.REAL_TIME_TRANSCRIPTION) { - // task.emit(CC_EVENTS.REAL_TIME_TRANSCRIPTION, message.data); - // } if (!message) return; // Step 2: Prepare event context @@ -704,6 +701,7 @@ export default class TaskManager extends EventEmitter { private requestRealTimeTranscripts(eventType: string, interactionId: string): void { const action = TRANSCRIPT_EVENT_MAP[eventType]; if (!action || !this.apiAIAssistant) return; + if (this.configFlags?.aiFeature?.realtimeTranscripts?.enable === false) return; this.apiAIAssistant .sendEvent( @@ -716,7 +714,7 @@ export default class TaskManager extends EventEmitter { .catch((error) => { LoggerProxy.error(`Failed to send transcript ${action} event`, { module: TASK_MANAGER_FILE, - method: 'requestRealTimeTranscripts', + method: METHODS.REQUEST_REAL_TIME_TRANSCRIPTS, interactionId, error, }); diff --git a/packages/@webex/contact-center/src/services/task/constants.ts b/packages/@webex/contact-center/src/services/task/constants.ts index 3aa7f4d408f..0ceebce3b5d 100644 --- a/packages/@webex/contact-center/src/services/task/constants.ts +++ b/packages/@webex/contact-center/src/services/task/constants.ts @@ -82,6 +82,7 @@ export const METHODS = { GET_TASK_MANAGER: 'getTaskManager', SETUP_AUTO_WRAPUP_TIMER: 'setupAutoWrapupTimer', CANCEL_AUTO_WRAPUP_TIMER: 'cancelAutoWrapupTimer', + REQUEST_REAL_TIME_TRANSCRIPTS: 'requestRealTimeTranscripts', }; export const TRANSCRIPT_EVENT_MAP = { diff --git a/packages/@webex/contact-center/src/types.ts b/packages/@webex/contact-center/src/types.ts index a0eb781b013..67267fbb398 100644 --- a/packages/@webex/contact-center/src/types.ts +++ b/packages/@webex/contact-center/src/types.ts @@ -6,7 +6,7 @@ import { } from '@webex/internal-plugin-metrics/src/metrics.types'; import * as Agent from './services/agent/types'; import * as Contact from './services/task/types'; -import {Profile} from './services/config/types'; +import {AIFeatureFlags, Profile} from './services/config/types'; import {PaginatedResponse, BaseSearchParams} from './utils/PageCache'; /** @@ -574,6 +574,7 @@ export type ConfigFlags = { isEndConsultEnabled: boolean; webRtcEnabled: boolean; autoWrapup: boolean; + aiFeature?: AIFeatureFlags; /** * Optional toggle to globally enable/disable recording controls. * Falls back to backend hints when omitted. @@ -857,7 +858,7 @@ export const AIAssistantEventType = { CUSTOM_EVENT: 'CUSTOM_EVENT', /** CTI-backed AI Assistant event */ CTI_EVENT: 'CTI_EVENT', -} as const; +}; /** * Union type of AI Assistant event categories. @@ -888,7 +889,7 @@ export const AIAssistantEventName = { POST_CALL_SUMMARY_RESPONSE: 'POST_CALL_SUMMARY_RESPONSE', /** Suggested digital response event */ SUGGESTED_RESPONSES_DIGITAL: 'SUGGESTED_RESPONSES_DIGITAL', -} as const; +}; /** * Union type of AI Assistant event names. diff --git a/packages/@webex/contact-center/test/unit/spec/services/ApiAiAssistant.ts b/packages/@webex/contact-center/test/unit/spec/services/ApiAiAssistant.ts index 82c7904dd2d..7845058eb88 100644 --- a/packages/@webex/contact-center/test/unit/spec/services/ApiAiAssistant.ts +++ b/packages/@webex/contact-center/test/unit/spec/services/ApiAiAssistant.ts @@ -1,6 +1,7 @@ import ApiAIAssistant from '../../../../src/services/ApiAiAssistant'; import MetricsManager from '../../../../src/metrics/MetricsManager'; import LoggerProxy from '../../../../src/logger-proxy'; +import WebexRequest from '../../../../src/services/core/WebexRequest'; import {HTTP_METHODS, WebexSDK} from '../../../../src/types'; jest.mock('../../../../src/metrics/MetricsManager'); @@ -112,4 +113,17 @@ describe('ApiAIAssistant', () => { expect(failed).toBe(true); expect(LoggerProxy.error).toHaveBeenCalled(); }); + + it('should fail when realtime transcripts feature is disabled', async () => { + apiAIAssistant.setAIFeatureFlags({realtimeTranscripts: {enable: false}} as any); + let errorMessage = ''; + + try { + await apiAIAssistant.fetchHistoricTranscripts('test-agent-id', 'interaction-1'); + } catch (error) { + errorMessage = (error as Error)?.message || ''; + } + + expect(errorMessage).toBe('REAL_TIME_TRANSCRIPTION_NOT_ENABLED'); + }); }); 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 b29c6ea5915..0ae07ad64e0 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 @@ -284,6 +284,35 @@ describe('TaskManager', () => { ); }); + it('should not invoke sendEvent when realtime transcripts are disabled in aiFeature', () => { + taskManager.setConfigFlags({ + isEndTaskEnabled: true, + isEndConsultEnabled: true, + webRtcEnabled: true, + autoWrapup: false, + aiFeature: { + id: 'ai-feature-1', + realtimeTranscripts: { + enable: false, + }, + }, + }); + + const message = (type: CC_EVENTS) => + JSON.stringify({ + data: { + ...taskDataMock, + interactionId: taskId, + type, + }, + }); + + webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONTACT_ASSIGNED)); + webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONSULTING)); + + expect(mockApiAIAssistant.sendEvent).not.toHaveBeenCalled(); + }); + it('should emit REAL_TIME_TRANSCRIPTION when eventType is in top-level payload', () => { const task = taskManager.getTask(taskId); const taskEmitSpy = jest.spyOn(task, 'emit'); From b6f935d0c841f68a08182877c142dcc7d0891927 Mon Sep 17 00:00:00 2001 From: Priya Date: Tue, 24 Mar 2026 13:57:31 +0530 Subject: [PATCH 06/10] feat(contact-center): review comments --- .../contact-center/test/unit/spec/services/ApiAiAssistant.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/@webex/contact-center/test/unit/spec/services/ApiAiAssistant.ts b/packages/@webex/contact-center/test/unit/spec/services/ApiAiAssistant.ts index 7845058eb88..251153c2046 100644 --- a/packages/@webex/contact-center/test/unit/spec/services/ApiAiAssistant.ts +++ b/packages/@webex/contact-center/test/unit/spec/services/ApiAiAssistant.ts @@ -14,6 +14,9 @@ describe('ApiAIAssistant', () => { beforeEach(() => { jest.clearAllMocks(); + jest.spyOn(WebexRequest, 'getInstance').mockReturnValue({ + uploadLogs: jest.fn(), + } as any); mockWebex = { credentials: { @@ -124,6 +127,6 @@ describe('ApiAIAssistant', () => { errorMessage = (error as Error)?.message || ''; } - expect(errorMessage).toBe('REAL_TIME_TRANSCRIPTION_NOT_ENABLED'); + expect(errorMessage).toBe('Error while performing fetchHistoricTranscripts'); }); }); From cf3e799174d03a05f39171206554d35580a76366 Mon Sep 17 00:00:00 2001 From: Priya Date: Tue, 24 Mar 2026 16:04:53 +0530 Subject: [PATCH 07/10] feat(contact-center): build fix --- packages/@webex/contact-center/src/services/ApiAiAssistant.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/@webex/contact-center/src/services/ApiAiAssistant.ts b/packages/@webex/contact-center/src/services/ApiAiAssistant.ts index b7dd6d52dc7..9f552a9715a 100644 --- a/packages/@webex/contact-center/src/services/ApiAiAssistant.ts +++ b/packages/@webex/contact-center/src/services/ApiAiAssistant.ts @@ -206,9 +206,7 @@ export class ApiAIAssistant { }, ['operational'] ); - if (error instanceof Error && error.message === AI_ASSISTANT_ERRORS.BASE_URL_NOT_AVAILABLE) { - throw error; - } + const {error: detailedError} = getErrorDetails( error, METHODS.FETCH_HISTORIC_TRANSCRIPTS, From f354f575491c19e73187b4e2f5bfd66dfef61cf1 Mon Sep 17 00:00:00 2001 From: Priya Date: Wed, 25 Mar 2026 20:01:48 +0530 Subject: [PATCH 08/10] feat(contact-center): unit tests failure fix --- .../src/services/ApiAiAssistant.ts | 19 +++++++------------ packages/@webex/contact-center/src/types.ts | 4 ++-- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/packages/@webex/contact-center/src/services/ApiAiAssistant.ts b/packages/@webex/contact-center/src/services/ApiAiAssistant.ts index 9f552a9715a..0bc818082c7 100644 --- a/packages/@webex/contact-center/src/services/ApiAiAssistant.ts +++ b/packages/@webex/contact-center/src/services/ApiAiAssistant.ts @@ -28,12 +28,10 @@ export class ApiAIAssistant { private webex: WebexSDK; private metricsManager: MetricsManager; private aiFeature: AIFeatureFlags; - private orgId: string; constructor(webex: WebexSDK) { this.webex = webex; this.metricsManager = MetricsManager.getInstance({webex}); - this.orgId = this.webex.credentials.getOrgId(); } public setAIFeatureFlags(aiFeature: AIFeatureFlags): void { @@ -41,12 +39,7 @@ export class ApiAIAssistant { } private getBaseUrl(): string { - let wccApiGatewayUrl = ''; - try { - wccApiGatewayUrl = this.webex.internal.services.get(WCC_API_GATEWAY) || ''; - } catch (_error) { - wccApiGatewayUrl = ''; - } + const wccApiGatewayUrl = this.webex.internal.services.get(WCC_API_GATEWAY) || ''; if (!wccApiGatewayUrl) { const {error: detailedError} = getErrorDetails( @@ -105,13 +98,14 @@ export class ApiAIAssistant { try { const baseUrl = this.getBaseUrl(); + const orgId = this.webex.credentials.getOrgId(); const response = (await this.webex.request({ uri: `${baseUrl}${AI_ASSISTANT_API_URLS.EVENT}`, method: HTTP_METHODS.POST, addAuthHeader: true, body: { agentId, - orgId: this.orgId, + orgId, eventType, eventName, eventDetails: { @@ -126,7 +120,7 @@ export class ApiAIAssistant { this.metricsManager.trackEvent( METRIC_EVENT_NAMES.AI_ASSISTANT_SEND_EVENT_SUCCESS, - {agentId, orgId: this.orgId, interactionId, eventType, eventName, action}, + {agentId, orgId, interactionId, eventType, eventName, action}, ['operational'] ); @@ -179,20 +173,21 @@ export class ApiAIAssistant { try { const baseUrl = this.getBaseUrl(); + const orgId = this.webex.credentials.getOrgId(); const response = (await this.webex.request({ uri: `${baseUrl}${AI_ASSISTANT_API_URLS.TRANSCRIPTS_LIST}`, method: HTTP_METHODS.POST, addAuthHeader: true, body: { agentId, - orgId: this.orgId, + orgId, interactionId, }, })) as IHttpResponse; this.metricsManager.trackEvent( METRIC_EVENT_NAMES.AI_ASSISTANT_FETCH_HISTORIC_TRANSCRIPTS_SUCCESS, - {agentId, orgId: this.orgId, interactionId}, + {agentId, orgId, interactionId}, ['operational'] ); diff --git a/packages/@webex/contact-center/src/types.ts b/packages/@webex/contact-center/src/types.ts index 67267fbb398..4aadb298f02 100644 --- a/packages/@webex/contact-center/src/types.ts +++ b/packages/@webex/contact-center/src/types.ts @@ -858,7 +858,7 @@ export const AIAssistantEventType = { CUSTOM_EVENT: 'CUSTOM_EVENT', /** CTI-backed AI Assistant event */ CTI_EVENT: 'CTI_EVENT', -}; +} as const; /** * Union type of AI Assistant event categories. @@ -889,7 +889,7 @@ export const AIAssistantEventName = { POST_CALL_SUMMARY_RESPONSE: 'POST_CALL_SUMMARY_RESPONSE', /** Suggested digital response event */ SUGGESTED_RESPONSES_DIGITAL: 'SUGGESTED_RESPONSES_DIGITAL', -}; +} as const; /** * Union type of AI Assistant event names. From a85fffeee0273c496284fb08caee191a1f29c76b Mon Sep 17 00:00:00 2001 From: Priya Date: Fri, 27 Mar 2026 12:46:39 +0530 Subject: [PATCH 09/10] feat(contact-center): new websocket implementation --- packages/@webex/contact-center/src/cc.ts | 39 ++++++- .../contact-center/src/services/constants.ts | 7 ++ .../core/websocket/WebSocketManager.ts | 15 ++- .../core/websocket/connection-service.ts | 6 +- .../contact-center/src/services/index.ts | 3 + .../src/services/task/TaskManager.ts | 52 +++++++-- .../src/services/task/constants.ts | 1 + .../contact-center/test/unit/spec/cc.ts | 15 +++ .../core/websocket/WebSocketManager.ts | 104 +++++++++++------- .../core/websocket/connection-service.ts | 7 +- .../unit/spec/services/task/TaskManager.ts | 43 +++++++- 11 files changed, 226 insertions(+), 66 deletions(-) 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: { From e5f53da1d6d8b97c754f43f98433610681a6f794 Mon Sep 17 00:00:00 2001 From: Priya Date: Fri, 27 Mar 2026 12:46:39 +0530 Subject: [PATCH 10/10] feat(contact-center): new websocket implementation --- .../contact-center/src/services/task/TaskManager.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/@webex/contact-center/src/services/task/TaskManager.ts b/packages/@webex/contact-center/src/services/task/TaskManager.ts index a74910f7916..25b00d403e8 100644 --- a/packages/@webex/contact-center/src/services/task/TaskManager.ts +++ b/packages/@webex/contact-center/src/services/task/TaskManager.ts @@ -359,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; @@ -429,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);