From 91a01ca19fa43a2f8c69870765616fa1e13200f1 Mon Sep 17 00:00:00 2001 From: arungane Date: Thu, 30 Oct 2025 21:20:42 -0400 Subject: [PATCH 01/14] feat(contact-center): multiparty conference skeleton --- packages/@webex/contact-center/package.json | 4 +- .../@webex/contact-center/src/constants.ts | 11 + .../contact-center/src/services/task/Task.ts | 434 +++++++++++++++++- .../src/services/task/TaskManager.ts | 4 +- .../src/services/task/constants.ts | 23 + .../src/services/task/digital/Digital.ts | 12 + .../contact-center/src/services/task/types.ts | 172 +++++++ .../src/services/task/voice/Voice.ts | 108 ++++- 8 files changed, 757 insertions(+), 11 deletions(-) diff --git a/packages/@webex/contact-center/package.json b/packages/@webex/contact-center/package.json index dd702346640..4380b4ed4e1 100644 --- a/packages/@webex/contact-center/package.json +++ b/packages/@webex/contact-center/package.json @@ -53,7 +53,8 @@ "@webex/plugin-logger": "workspace:*", "@webex/webex-core": "workspace:*", "jest-html-reporters": "3.0.11", - "lodash": "^4.17.21" + "lodash": "^4.17.21", + "xstate": "^4.38.0" }, "devDependencies": { "@babel/core": "^7.22.11", @@ -66,6 +67,7 @@ "@webex/jest-config-legacy": "workspace:*", "@webex/legacy-tools": "workspace:*", "@webex/test-helper-mock-webex": "workspace:*", + "@xstate/inspect": "^0.8.0", "eslint": "^8.24.0", "eslint-config-airbnb-base": "15.0.0", "eslint-config-prettier": "8.3.0", diff --git a/packages/@webex/contact-center/src/constants.ts b/packages/@webex/contact-center/src/constants.ts index d8704e5896d..21948a48260 100644 --- a/packages/@webex/contact-center/src/constants.ts +++ b/packages/@webex/contact-center/src/constants.ts @@ -49,4 +49,15 @@ export const METHODS = { HANDLE_INCOMING_TASK: 'handleIncomingTask', HANDLE_TASK_HYDRATE: 'handleTaskHydrate', INCOMING_TASK_LISTENER: 'incomingTaskListener', + ACCEPT: 'accept', + REJECT: 'decline', + HOLD: 'hold', + RESUME: 'resume', + HOLD_RESUME: 'holdResume', + TRANSFER_CALL: 'transfer', + CONSULT_TRANSFER: 'consultTransfer', + CONSULT_CONFERENCE: 'consultConference', + EXIT_CONFERENCE: 'exitConference', + TRANSFER_CONFERENCE: 'transferConference', + TOGGLE_MUTE: 'toggleMute', }; diff --git a/packages/@webex/contact-center/src/services/task/Task.ts b/packages/@webex/contact-center/src/services/task/Task.ts index 4a783f4c7fc..88f9f141632 100644 --- a/packages/@webex/contact-center/src/services/task/Task.ts +++ b/packages/@webex/contact-center/src/services/task/Task.ts @@ -1,5 +1,6 @@ import {EventEmitter} from 'events'; import {CallId} from '@webex/calling/dist/types/common/types'; +import {interpret, Interpreter} from 'xstate'; import { ITask, TaskData, @@ -8,7 +9,7 @@ import { TaskId, TransferPayLoad, TaskButtonControl, - TaskUIControls, + TaskUIActions, DESTINATION_TYPE, } from './types'; import {CC_FILE} from '../../constants'; @@ -17,13 +18,27 @@ import routingContact from './contact'; import MetricsManager from '../../metrics/MetricsManager'; import {METRIC_EVENT_NAMES} from '../../metrics/constants'; import LoggerProxy from '../../logger-proxy'; +import { + createTaskStateMachineWithActions, + createActionsWithCallbacks, + TaskEvent, + TaskState, + TaskContext, + TaskEventPayload, + type ActionCallbacks, + guards, +} from './state-machine'; +import AutoWrapup from './AutoWrapup'; export default abstract class Task extends EventEmitter implements ITask { protected contact: ReturnType; protected metricsManager: MetricsManager; + protected stateMachineService?: Interpreter; public data: TaskData; public webCallMap: Record; - public taskUiControls: TaskUIControls; + public taskUiControls: TaskUIActions; + private ronaTimerId?: NodeJS.Timeout; + private autoWrapupTimerId?: NodeJS.Timeout; constructor(contact: ReturnType, data: TaskData) { super(); @@ -32,6 +47,264 @@ export default abstract class Task extends EventEmitter implements ITask { this.metricsManager = MetricsManager.getInstance(); this.webCallMap = {}; this.initialiseUIControls(); + this.initializeStateMachine(); + } + + // Properties from ITask interface + public autoWrapup?: AutoWrapup; + + // Abstract method that all child classes must implement + public abstract accept(): Promise; + + // Voice-specific methods with default implementations that throw errors + // Voice class will override these with actual implementations + public async decline(): Promise { + this.unsupportedMethodError('decline'); + + return Promise.reject(new Error('decline not supported for this channel type')); + } + + public async pauseRecording(): Promise { + this.unsupportedMethodError('pauseRecording'); + + return Promise.reject(new Error('pauseRecording not supported for this channel type')); + } + + public async resumeRecording(): Promise { + this.unsupportedMethodError('resumeRecording'); + + return Promise.reject(new Error('resumeRecording not supported for this channel type')); + } + + public async consult(): Promise { + this.unsupportedMethodError('consult'); + + return Promise.reject(new Error('consult not supported for this channel type')); + } + + public async endConsult(): Promise { + this.unsupportedMethodError('endConsult'); + + return Promise.reject(new Error('endConsult not supported for this channel type')); + } + + public async consultTransfer(): Promise { + this.unsupportedMethodError('consultTransfer'); + + return Promise.reject(new Error('consultTransfer not supported for this channel type')); + } + + public async consultConference(): Promise { + this.unsupportedMethodError('consultConference'); + + return Promise.reject(new Error('consultConference not supported for this channel type')); + } + + public async exitConference(): Promise { + this.unsupportedMethodError('exitConference'); + + return Promise.reject(new Error('exitConference not supported for this channel type')); + } + + public async transferConference(): Promise { + this.unsupportedMethodError('transferConference'); + + return Promise.reject(new Error('transferConference not supported for this channel type')); + } + + public async toggleMute(): Promise { + this.unsupportedMethodError('toggleMute'); + + return Promise.reject(new Error('toggleMute not supported for this channel type')); + } + + // Utility methods with default implementations + public cancelAutoWrapupTimer(): void { + // Default implementation - child classes can override + if (this.autoWrapupTimerId) { + clearTimeout(this.autoWrapupTimerId); + this.autoWrapupTimerId = undefined; + } + } + + public unregisterWebCallListeners(): void { + // Default implementation - child classes can override + LoggerProxy.log('unregisterWebCallListeners called', { + module: CC_FILE, + method: 'unregisterWebCallListeners', + }); + } + + // Voice tasks use holdResume(), but provide separate methods for interface compliance + public async hold(): Promise { + throw new Error('hold() not implemented. Use holdResume() for voice tasks.'); + } + + public async resume(): Promise { + throw new Error('resume() not implemented. Use holdResume() for voice tasks.'); + } + + /** + * Initialize the state machine with custom action callbacks + */ + private initializeStateMachine(): void { + const callbacks: ActionCallbacks = { + onTaskIncoming: (taskData) => { + LoggerProxy.log('State machine: Task incoming', { + module: CC_FILE, + method: 'onTaskIncoming', + interactionId: taskData.interactionId, + }); + }, + onTaskAssigned: (taskData) => { + LoggerProxy.log('State machine: Task assigned', { + module: CC_FILE, + method: 'onTaskAssigned', + interactionId: taskData.interactionId, + }); + }, + onStartRonaTimer: (timeout) => { + this.startRonaTimer(timeout); + + return null; + }, + onStopRonaTimer: () => { + this.stopRonaTimer(); + }, + onStartAutoWrapupTimer: (timeout) => { + this.startAutoWrapupTimer(timeout); + + return null; + }, + onStopAutoWrapupTimer: () => { + this.stopAutoWrapupTimer(); + }, + onCleanupResources: () => { + this.cleanupResources(); + }, + }; + + const customActions = createActionsWithCallbacks(callbacks); + const machine = createTaskStateMachineWithActions(customActions); + + this.stateMachineService = interpret(machine) + .onTransition(() => { + LoggerProxy.log('State machine transition', { + module: CC_FILE, + method: 'onTransition', + }); + + // Compute derived properties after state transition + const agentId = this.data.agentId; + if (agentId) { + this.computeDerivedProperties(agentId); + } + + // Update UI controls based on current state + this.updateUIControlsFromState(); + }) + .start(); + } + + /** + * Send an event to the state machine + */ + protected sendStateMachineEvent(event: TaskEventPayload): void { + if (this.stateMachineService) { + this.stateMachineService.send(event); + } + } + + /** + * Get the current state machine state + */ + protected getCurrentState(): TaskState | undefined { + return this.stateMachineService?.state?.value as TaskState; + } + + /** + * Update UI controls based on the current state machine state + * Child classes should override this to provide specific UI control logic + */ + protected updateUIControlsFromState(): void { + // Default implementation - child classes should override + LoggerProxy.log('Updating UI controls from state', { + module: CC_FILE, + method: 'updateUIControlsFromState', + }); + } + + /** + * Start RONA (Ring on No Answer) timer + */ + private startRonaTimer(timeout: number): void { + this.stopRonaTimer(); + this.ronaTimerId = setTimeout(() => { + LoggerProxy.warn('RONA timeout reached', { + module: CC_FILE, + method: 'startRonaTimer', + interactionId: this.data.interactionId, + }); + this.sendStateMachineEvent({type: TaskEvent.RONA}); + }, timeout); + } + + /** + * Stop RONA timer + */ + private stopRonaTimer(): void { + if (this.ronaTimerId) { + clearTimeout(this.ronaTimerId); + this.ronaTimerId = undefined; + } + } + + /** + * Start auto-wrapup timer + */ + private startAutoWrapupTimer(timeout: number): void { + this.stopAutoWrapupTimer(); + this.autoWrapupTimerId = setTimeout(() => { + LoggerProxy.log('Auto-wrapup timeout reached', { + module: CC_FILE, + method: 'startAutoWrapupTimer', + interactionId: this.data.interactionId, + }); + this.sendStateMachineEvent({type: TaskEvent.AUTO_WRAPUP}); + }, timeout); + } + + /** + * Stop auto-wrapup timer + */ + private stopAutoWrapupTimer(): void { + if (this.autoWrapupTimerId) { + clearTimeout(this.autoWrapupTimerId); + this.autoWrapupTimerId = undefined; + } + } + + /** + * Cleanup task resources (WebRTC, timers, etc.) + */ + private cleanupResources(): void { + this.stopRonaTimer(); + this.stopAutoWrapupTimer(); + LoggerProxy.log('Cleaning up task resources', { + module: CC_FILE, + method: 'cleanupResources', + interactionId: this.data.interactionId, + }); + } + + /** + * Stop the state machine service + */ + protected stopStateMachine(): void { + if (this.stateMachineService) { + this.stateMachineService.stop(); + this.stateMachineService = undefined; + } } private reconcileData(oldData: TaskData, newData: TaskData): TaskData { @@ -97,6 +370,150 @@ export default abstract class Task extends EventEmitter implements ITask { }); } + /** + * Compute derived properties from state machine context + * Called whenever task data is updated or state transitions occur + */ + protected computeDerivedProperties(agentId: string): void { + const context = this.stateMachineService?.state?.context; + if (!context) return; + + try { + // Compute consultStatus + this.data.consultStatus = this.getConsultStatusFromContext(context, agentId); + + // Compute isConsultInProgress + this.data.isConsultInProgress = guards.isConsulting(context); + + // Compute isOnHold + this.data.isOnHold = guards.isHeld(context); + + // Compute isConferenceInProgress (already exists but ensure consistency) + this.data.isConferenceInProgress = + guards.isConferencing(context) && context.participants.length >= 2; + + // Compute isCustomerInCall + this.data.isCustomerInCall = this.checkCustomerInCall(); + + // Compute conferenceParticipantsCount + this.data.conferenceParticipantsCount = context.participants.length; + + // Compute isSecondaryAgent + this.data.isSecondaryAgent = this.checkIsSecondaryAgent(); + + // Compute isSecondaryEpDnAgent + this.data.isSecondaryEpDnAgent = + this.data.interaction.mediaType === 'telephony' && this.data.isSecondaryAgent; + + // Compute mpcState + this.data.mpcState = this.getMPCState(agentId); + } catch (error) { + LoggerProxy.error('Error computing derived properties', { + module: CC_FILE, + method: 'computeDerivedProperties', + error: error.message, + }); + } + } + + /** + * Get consultation status from state machine context + */ + private getConsultStatusFromContext(context: TaskContext, agentId: string): string { + const state = context.currentState; + const participants = this.data.interaction?.participants || {}; + const participant: any = Object.values(participants).find( + (p: any) => p.pType === 'Agent' && p.id === agentId + ); + + if (state === TaskState.CONSULT_INITIATED) { + return participant?.isConsulted ? 'BEING_CONSULTED' : 'CONSULT_INITIATED'; + } + if (state === TaskState.CONSULTING) { + return participant?.isConsulted ? 'BEING_CONSULTED_ACCEPTED' : 'CONSULT_ACCEPTED'; + } + if (state === TaskState.CONNECTED) { + return 'CONNECTED'; + } + if (state === TaskState.CONFERENCING) { + return 'CONFERENCE'; + } + if (state === TaskState.CONSULT_COMPLETED) { + return 'CONSULT_COMPLETED'; + } + + return 'NO_CONSULTATION_IN_PROGRESS'; + } + + /** + * Check if customer is in call + */ + private checkCustomerInCall(): boolean { + if (!this.data?.interaction?.media || !this.data?.interactionId) { + return false; + } + + const mediaMainCall = this.data.interaction.media[this.data.interactionId]; + const participantsInMainCall = new Set(mediaMainCall?.participants); + const participants = this.data.interaction?.participants; + + if (participantsInMainCall.size > 0 && participants) { + return Array.from(participantsInMainCall).some((participantId: string) => { + const participant = participants[participantId]; + + return participant && participant.pType === 'CUSTOMER' && !participant.hasLeft; + }); + } + + return false; + } + + /** + * Check if this is a secondary agent (consulted party) + */ + private checkIsSecondaryAgent(): boolean { + const interaction = this.data.interaction; + + return ( + !!interaction.callProcessingDetails && + interaction.callProcessingDetails.relationshipType === 'CONSULT' && + !!interaction.callProcessingDetails.parentInteractionId && + interaction.callProcessingDetails.parentInteractionId !== interaction.interactionId + ); + } + + /** + * Get MPC state based on participant consultState + */ + private getMPCState(agentId: string): string { + const interaction = this.data.interaction; + const currentState = this.getCurrentState(); + + if ( + !this.data.consultMediaResourceId || + !interaction.participants[agentId]?.consultState || + currentState === TaskState.WRAPPING_UP || + currentState === TaskState.POST_CALL + ) { + return interaction?.state || (currentState as string); + } + + const consultState = interaction.participants[agentId]?.consultState; + + switch (consultState) { + case 'INITIATED': + return TaskState.CONSULT_INITIATED; + case 'COMPLETED': + return currentState === TaskState.CONNECTED + ? TaskState.CONNECTED + : TaskState.CONSULT_COMPLETED; + case 'CONFERENCING': + return TaskState.CONFERENCING; + default: + return TaskState.CONSULTING; + } + } + /** * This method is used to update the task data. * @param updatedData - TaskData @@ -107,12 +524,19 @@ export default abstract class Task extends EventEmitter implements ITask { * task.updateTaskData(updatedData, true); * ``` */ - public updateTaskData(updatedData: TaskData, shouldOverwrite = false) { + public updateTaskData(updatedData: TaskData, shouldOverwrite = false): ITask { this.data = shouldOverwrite ? updatedData : this.reconcileData(this.data, updatedData); + + // Compute derived properties from state machine + const agentId = this.data.agentId; + if (agentId) { + this.computeDerivedProperties(agentId); + } + this.setUIControls(); - } - public abstract accept(): Promise; + return this; + } /** * This is used to blind transfer or vTeam transfer the task diff --git a/packages/@webex/contact-center/src/services/task/TaskManager.ts b/packages/@webex/contact-center/src/services/task/TaskManager.ts index e1d72789199..d527426cd29 100644 --- a/packages/@webex/contact-center/src/services/task/TaskManager.ts +++ b/packages/@webex/contact-center/src/services/task/TaskManager.ts @@ -124,7 +124,7 @@ export default class TaskManager extends EventEmitter { if (!task) { // Re-create task if it does not exist // This can happen when the task is created after the event is received (multi session) - TaskFactory.createTask( + task = TaskFactory.createTask( this.contact, this.webCallingService, {...payload.data, isConsulted: false}, @@ -137,7 +137,7 @@ export default class TaskManager extends EventEmitter { break; case CC_EVENTS.AGENT_CONTACT_RESERVED: - TaskFactory.createTask( + task = TaskFactory.createTask( this.contact, this.webCallingService, {...payload.data, isConsulted: false}, diff --git a/packages/@webex/contact-center/src/services/task/constants.ts b/packages/@webex/contact-center/src/services/task/constants.ts index 53a79177bba..09fda31c892 100644 --- a/packages/@webex/contact-center/src/services/task/constants.ts +++ b/packages/@webex/contact-center/src/services/task/constants.ts @@ -42,6 +42,29 @@ export const PRESERVED_TASK_DATA_FIELDS = { */ export const KEYS_TO_NOT_DELETE: string[] = Object.values(PRESERVED_TASK_DATA_FIELDS); +/** + * Consultation status constants derived from state machine + * These values are computed and available in task.data.consultStatus + */ +export const CONSULT_STATUS = { + /** No consultation is currently in progress */ + NO_CONSULTATION_IN_PROGRESS: 'NO_CONSULTATION_IN_PROGRESS', + /** Consultation has been initiated but not yet accepted */ + CONSULT_INITIATED: 'CONSULT_INITIATED', + /** Consultation has been accepted and is in progress */ + CONSULT_ACCEPTED: 'CONSULT_ACCEPTED', + /** This agent is being consulted (has received consult request) */ + BEING_CONSULTED: 'BEING_CONSULTED', + /** This agent is being consulted and has accepted */ + BEING_CONSULTED_ACCEPTED: 'BEING_CONSULTED_ACCEPTED', + /** Task is in connected state */ + CONNECTED: 'CONNECTED', + /** Task is in conference state */ + CONFERENCE: 'CONFERENCE', + /** Consultation has been completed */ + CONSULT_COMPLETED: 'CONSULT_COMPLETED', +} as const; + // METHOD NAMES export const METHODS = { // Task class methods diff --git a/packages/@webex/contact-center/src/services/task/digital/Digital.ts b/packages/@webex/contact-center/src/services/task/digital/Digital.ts index a7e1b1995fc..f4acfdbde1f 100644 --- a/packages/@webex/contact-center/src/services/task/digital/Digital.ts +++ b/packages/@webex/contact-center/src/services/task/digital/Digital.ts @@ -14,6 +14,18 @@ export default class Digital extends Task implements IDigital { this.updateTaskUiControls({accept: [true, true]}); } + /** + * Updates the task data with new information + * @param newData - Updated task data to apply + * @param shouldOverwrite - Whether to completely replace existing data + * @returns Updated Digital task instance + */ + public updateTaskData(newData: TaskData, shouldOverwrite = false): IDigital { + super.updateTaskData(newData, shouldOverwrite); + + return this; + } + /** * This is used for incoming digital task accept by agent. * diff --git a/packages/@webex/contact-center/src/services/task/types.ts b/packages/@webex/contact-center/src/services/task/types.ts index fea840339b5..16d541f7d70 100644 --- a/packages/@webex/contact-center/src/services/task/types.ts +++ b/packages/@webex/contact-center/src/services/task/types.ts @@ -1,3 +1,5 @@ +/* eslint-disable import/no-cycle */ +// eslint-disable-next-line import/no-unresolved import {CallId} from '@webex/calling/dist/types/common/types'; import EventEmitter from 'events'; import {Msg} from '../core/GlobalTypes'; @@ -761,8 +763,123 @@ export type TaskData = { reservationInteractionId?: string; /** Indicates if wrap-up is required for this task */ wrapUpRequired?: boolean; + + /** + * Current consultation status derived from state machine + * Values: CONSULT_INITIATED, CONSULT_ACCEPTED, BEING_CONSULTED, + * BEING_CONSULTED_ACCEPTED, CONNECTED, CONFERENCE, CONSULT_COMPLETED + */ + consultStatus?: string; + + /** + * Indicates if consultation is in progress (state machine: CONSULTING) + */ + isConsultInProgress?: boolean; + + /** + * Indicates if the task is on hold (state machine: HELD) + */ + isOnHold?: boolean; + + /** + * Indicates if customer is currently in the call + * Derived from participants in main media + */ + isCustomerInCall?: boolean; + + /** + * Count of conference participants (agents only) + * Used for determining if max participants reached + */ + conferenceParticipantsCount?: number; + + /** + * Indicates if this is a secondary agent (consulted party) + */ + isSecondaryAgent?: boolean; + + /** + * Indicates if this is a secondary EP-DN agent (telephony consult to external) + */ + isSecondaryEpDnAgent?: boolean; + + /** + * Task state for MPC (Multi-Party Conference) scenarios + * Maps participant consultState to task state + */ + mpcState?: string; +}; + +/** + * Helper class for managing task action control state + * Tracks visibility and enabled state for task actions that can be executed + * @public + */ +export class TaskActionControl { + public visible: boolean; + private enabled: boolean; + + constructor(visible: boolean, enabled: boolean) { + this.visible = visible; + this.enabled = enabled; + } + + setVisiblity(visible: boolean): void { + this.visible = visible; + } + + setEnabled(enabled: boolean): void { + this.enabled = enabled; + } + + isVisible(): boolean { + return this.visible; + } + + isEnabled(): boolean { + return this.enabled; + } +} + +/** + * UI actions configuration for task operations + * Maps each available action to its control state + * This is used by the UI to determine which actions can be performed + * @public + */ +export type TaskUIActions = { + accept: TaskActionControl; + decline: TaskActionControl; + hold: TaskActionControl; + mute: TaskActionControl; + end: TaskActionControl; + transfer: TaskActionControl; + consult: TaskActionControl; + consultTransfer: TaskActionControl; + endConsult: TaskActionControl; + recording: TaskActionControl; + conference: TaskActionControl; + wrapup: TaskActionControl; + /** NEW: Agent exits from an ongoing conference */ + exitConference?: TaskActionControl; + /** NEW: Transfer entire conference to another destination */ + transferConference?: TaskActionControl; + /** NEW: Merge consultation to conference */ + mergeToConference?: TaskActionControl; }; +/** + * @deprecated Use TaskActionControl instead + * @public + */ +export const TaskButtonControl = TaskActionControl; + +/** + * @deprecated Use TaskUIActions instead + * @public + */ +export type TaskUIControls = TaskUIActions; + /** * Type representing an agent contact message within the contact center system * Contains comprehensive interaction and task related details for agent operations @@ -1334,3 +1451,58 @@ export interface ITask extends EventEmitter { */ toggleMute(): Promise; } + +/** + * Interface for managing digital channel task operations in the contact center + * Digital channels (chat, email, social, SMS) have a simpler interface than voice + * Extends ITask but overrides updateTaskData to return IDigital + * @public + */ +export interface IDigital extends Omit { + /** + * UI controls configuration + */ + taskUiControls: TaskUIActions; + + /** + * Updates the task data + * @param newData - Updated task data + * @param shouldOverwrite - Whether to completely replace existing data + * @returns Updated Digital task instance + */ + updateTaskData(newData: TaskData, shouldOverwrite?: boolean): IDigital; +} + +/** + * Interface for managing voice/telephony task operations in the contact center + * Extends ITask with voice-specific functionality for hold/resume operations + * @public + */ +export interface IVoice extends ITask { + /** + * Toggles hold/resume state for a voice task. + * If the task is currently on hold, it will be resumed. + * If the task is active, it will be placed on hold. + * @returns Promise + * @example + * ```typescript + * await voiceTask.holdResume(); + * ``` + */ + holdResume(): Promise; +} + +/** + * Legacy IOldTask interface for backward compatibility + * @deprecated Use ITask, IVoice, or IDigital instead + * @ignore + */ +export type IOldTask = ITask; + +/** + * Legacy IWebRTC interface - maintained for backward compatibility + * @deprecated + * @ignore + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface IWebRTC {} diff --git a/packages/@webex/contact-center/src/services/task/voice/Voice.ts b/packages/@webex/contact-center/src/services/task/voice/Voice.ts index d11b810f60a..d1a9cab653f 100644 --- a/packages/@webex/contact-center/src/services/task/voice/Voice.ts +++ b/packages/@webex/contact-center/src/services/task/voice/Voice.ts @@ -420,7 +420,7 @@ export default class Voice extends Task implements IVoice { * ``` */ public async resumeRecording( - resumeRecordingPayload: ResumeRecordingPayload + resumeRecordingPayload?: ResumeRecordingPayload ): Promise { try { LoggerProxy.info(`Resuming recording`, { @@ -483,7 +483,7 @@ export default class Voice extends Task implements IVoice { * task.consult(consultPayload).then(()=>{}).catch(()=>{}); * ``` * */ - public async consult(consultPayload: ConsultPayload): Promise { + public async consult(consultPayload?: ConsultPayload): Promise { try { LoggerProxy.info(`Starting consult`, { module: CC_FILE, @@ -547,7 +547,7 @@ export default class Voice extends Task implements IVoice { * }); * ``` */ - public async endConsult(consultEndPayload: ConsultEndPayload): Promise { + public async endConsult(consultEndPayload?: ConsultEndPayload): Promise { try { LoggerProxy.info(`Ending consult`, { module: CC_FILE, @@ -683,4 +683,106 @@ export default class Voice extends Task implements IVoice { throw detailedError; } } + + /** + * Performs a consult transfer + * @param consultTransferPayload - Optional payload for consult transfer + * @returns Promise + * @throws Error + */ + public async consultTransfer( + consultTransferPayload?: ConsultTransferPayLoad + ): Promise { + try { + LoggerProxy.info('Performing consult transfer', { + module: CC_FILE, + method: METHODS.CONSULT_TRANSFER, + interactionId: this.data.interactionId, + }); + + let payload: ConsultTransferPayLoad; + if (consultTransferPayload) { + payload = consultTransferPayload; + } else if (this.data.destAgentId) { + payload = { + to: this.data.destAgentId, + destinationType: CONSULT_TRANSFER_DESTINATION_TYPE.AGENT, + }; + } else { + throw new Error('No destination specified for consult transfer'); + } + + this.metricsManager.timeEvent([ + METRIC_EVENT_NAMES.TASK_TRANSFER_SUCCESS, + METRIC_EVENT_NAMES.TASK_TRANSFER_FAILED, + ]); + + const result = await this.contact.consultTransfer({ + interactionId: this.data.interactionId, + data: payload, + }); + + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.TASK_TRANSFER_SUCCESS, + { + taskId: this.data.interactionId, + destination: payload.to, + destinationType: payload.destinationType, + isConsultTransfer: true, + ...MetricsManager.getCommonTrackingFieldForAQMResponse(result), + }, + ['operational', 'behavioral', 'business'] + ); + + return result; + } catch (error) { + const {error: detailedError} = getErrorDetails(error, METHODS.CONSULT_TRANSFER, CC_FILE); + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.TASK_TRANSFER_FAILED, + { + taskId: this.data.interactionId, + error: error.toString(), + ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed(error.details || {}), + }, + ['operational', 'behavioral', 'business'] + ); + throw detailedError; + } + } + + /** + * Initiates a consult conference (merge consult call with main call) + * @returns Promise + * @throws Error + */ + public async consultConference(): Promise { + super.unsupportedMethodError(METHODS.CONSULT_CONFERENCE); + } + + /** + * Exits from an ongoing conference + * @returns Promise + * @throws Error + */ + public async exitConference(): Promise { + super.unsupportedMethodError(METHODS.EXIT_CONFERENCE); + } + + /** + * Transfers the conference to another participant + * @returns Promise + * @throws Error + */ + public async transferConference(): Promise { + super.unsupportedMethodError(METHODS.TRANSFER_CONFERENCE); + } + + /** + * Toggles mute/unmute for the local audio stream during a WebRTC task + * @returns Promise + * @throws Error + */ + public async toggleMute(): Promise { + super.unsupportedMethodError(METHODS.TOGGLE_MUTE); + } } From ec119cecf77c2c8be1bfaf7d718c428a252a0b19 Mon Sep 17 00:00:00 2001 From: arungane Date: Sat, 1 Nov 2025 23:07:08 -0400 Subject: [PATCH 02/14] feat(contact-center): with state management --- docs/samples/contact-center/app.js | 11 +- .../contact-center/src/services/task/Task.ts | 64 ++- .../src/services/task/TaskManager.ts | 138 +++++ .../task/state-machine/TaskStateMachine.ts | 308 ++++++++++++ .../services/task/state-machine/actions.ts | 469 +++++++++++++++++ .../src/services/task/state-machine/guards.ts | 318 ++++++++++++ .../src/services/task/state-machine/index.ts | 24 + .../src/services/task/state-machine/types.ts | 311 ++++++++++++ .../contact-center/src/services/task/types.ts | 17 + .../src/services/task/voice/Voice.ts | 472 +++++++++++------- .../unit/spec/services/task/voice/Voice.ts | 62 +++ yarn.lock | 20 +- 12 files changed, 2024 insertions(+), 190 deletions(-) create mode 100644 packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts create mode 100644 packages/@webex/contact-center/src/services/task/state-machine/actions.ts create mode 100644 packages/@webex/contact-center/src/services/task/state-machine/guards.ts create mode 100644 packages/@webex/contact-center/src/services/task/state-machine/index.ts create mode 100644 packages/@webex/contact-center/src/services/task/state-machine/types.ts diff --git a/docs/samples/contact-center/app.js b/docs/samples/contact-center/app.js index 93e626e9363..a6dcdf14d90 100644 --- a/docs/samples/contact-center/app.js +++ b/docs/samples/contact-center/app.js @@ -700,6 +700,16 @@ async function initiateConsultTransfer() { } } +function toggleTransferOptions() { + const transferOptions = document.getElementById('transfer-options'); + if (transferOptions.style.display === 'none') { + transferOptions.style.display = 'block'; + onTransferTypeSelectionChanged(); // To load the default destination type view + } else { + transferOptions.style.display = 'none'; + } +} + // Function to end consult async function endConsult() { const taskId = currentTask.data?.interactionId; @@ -2306,4 +2316,3 @@ updateLoginOptionElm.addEventListener('change', updateApplyButtonState); updateDialNumberElm.addEventListener('input', updateApplyButtonState); updateApplyButtonState(); - diff --git a/packages/@webex/contact-center/src/services/task/Task.ts b/packages/@webex/contact-center/src/services/task/Task.ts index 88f9f141632..a4795b99fc5 100644 --- a/packages/@webex/contact-center/src/services/task/Task.ts +++ b/packages/@webex/contact-center/src/services/task/Task.ts @@ -40,6 +40,50 @@ export default abstract class Task extends EventEmitter implements ITask { private ronaTimerId?: NodeJS.Timeout; private autoWrapupTimerId?: NodeJS.Timeout; + /** + * State machine instance for managing task state transitions and derived properties. + * Exposed publicly to allow access to state machine context and current state. + * @internal + */ + public stateMachine?: Interpreter; + + // State machine derived properties (getters) + public get canHold(): boolean { + return this.stateMachine?.state.context.canHold ?? false; + } + + public get canResume(): boolean { + return this.stateMachine?.state.context.canResume ?? false; + } + + public get canConsult(): boolean { + return this.stateMachine?.state.context.canConsult ?? false; + } + + public get canEndConsult(): boolean { + return this.stateMachine?.state.context.canEndConsult ?? false; + } + + public get canTransfer(): boolean { + return this.stateMachine?.state.context.canTransfer ?? false; + } + + public get canWrapup(): boolean { + return this.stateMachine?.state.context.canWrapup ?? false; + } + + public get isHeld(): boolean { + return this.stateMachine?.state.matches(TaskState.HELD) ?? false; + } + + public get isConsulting(): boolean { + return this.stateMachine?.state.matches(TaskState.CONSULTING) ?? false; + } + + public get isConferencing(): boolean { + return this.stateMachine?.state.matches(TaskState.CONFERENCING) ?? false; + } + constructor(contact: ReturnType, data: TaskData) { super(); this.contact = contact; @@ -48,6 +92,8 @@ export default abstract class Task extends EventEmitter implements ITask { this.webCallMap = {}; this.initialiseUIControls(); this.initializeStateMachine(); + // Expose stateMachineService as public stateMachine for ITask interface compliance + this.stateMachine = this.stateMachineService; } // Properties from ITask interface @@ -137,11 +183,21 @@ export default abstract class Task extends EventEmitter implements ITask { // Voice tasks use holdResume(), but provide separate methods for interface compliance public async hold(): Promise { - throw new Error('hold() not implemented. Use holdResume() for voice tasks.'); + this.unsupportedMethodError('hold'); + + return Promise.reject(new Error('hold not supported for this channel type')); } public async resume(): Promise { - throw new Error('resume() not implemented. Use holdResume() for voice tasks.'); + this.unsupportedMethodError('resume'); + + return Promise.reject(new Error('resume not supported for this channel type')); + } + + public async holdResume(): Promise { + this.unsupportedMethodError('holdResume'); + + return Promise.reject(new Error('holdResume not supported for this channel type')); } /** @@ -204,6 +260,9 @@ export default abstract class Task extends EventEmitter implements ITask { this.updateUIControlsFromState(); }) .start(); + + // Expose as public property for ITask interface + this.stateMachine = this.stateMachineService; } /** @@ -304,6 +363,7 @@ export default abstract class Task extends EventEmitter implements ITask { if (this.stateMachineService) { this.stateMachineService.stop(); this.stateMachineService = undefined; + this.stateMachine = undefined; } } diff --git a/packages/@webex/contact-center/src/services/task/TaskManager.ts b/packages/@webex/contact-center/src/services/task/TaskManager.ts index d527426cd29..1a7a317928a 100644 --- a/packages/@webex/contact-center/src/services/task/TaskManager.ts +++ b/packages/@webex/contact-center/src/services/task/TaskManager.ts @@ -19,6 +19,7 @@ import { isPrimary, } from './TaskUtils'; import WebRTC from './voice/WebRTC'; +import {TaskEvent, type TaskEventPayload} from './state-machine'; /** @internal */ export default class TaskManager extends EventEmitter { private call: ICall; @@ -105,6 +106,137 @@ export default class TaskManager extends EventEmitter { this.webCallingService.off(LINE_EVENTS.INCOMING_CALL, this.handleIncomingWebCall); } + /** + * Map WebSocket CC_EVENTS to state machine TaskEvent + * @param ccEvent - The CC_EVENT type from WebSocket + * @param payload - The event payload + * @returns TaskEventPayload for state machine or null if no mapping + */ + private mapWebSocketEventToStateMachineEvent( + ccEvent: CC_EVENTS, + payload: any + ): TaskEventPayload | null { + const mediaResourceId = + payload.data?.mediaResourceId || + payload.data?.interaction?.media?.[payload.data?.interactionId]?.mediaResourceId; + + switch (ccEvent) { + case CC_EVENTS.AGENT_OFFER_CONTACT: + return {type: TaskEvent.OFFER, taskData: payload.data}; + + case CC_EVENTS.AGENT_OFFER_CONSULT: + return {type: TaskEvent.OFFER_CONSULT, taskData: payload.data}; + + case CC_EVENTS.AGENT_CONTACT_ASSIGNED: + return {type: TaskEvent.ASSIGN, taskData: payload.data}; + + case CC_EVENTS.AGENT_CONTACT_HELD: + return {type: TaskEvent.HOLD, mediaResourceId: mediaResourceId || ''}; + + case CC_EVENTS.AGENT_CONTACT_UNHELD: + return {type: TaskEvent.UNHOLD, mediaResourceId: mediaResourceId || ''}; + + case CC_EVENTS.AGENT_CONSULT_CREATED: + return {type: TaskEvent.CONSULT_CREATED, taskData: payload.data}; + + case CC_EVENTS.AGENT_CONSULTING: + return { + type: TaskEvent.CONSULTING_ACTIVE, + consultDestinationAgentJoined: true, + }; + + case CC_EVENTS.AGENT_CONSULT_ENDED: + return {type: TaskEvent.CONSULT_END}; + + case CC_EVENTS.AGENT_CONSULT_FAILED: + return {type: TaskEvent.CONSULT_FAILED, reason: payload.data?.reason}; + + case CC_EVENTS.AGENT_CTQ_CANCELLED: + return {type: TaskEvent.CTQ_CANCEL}; + + case CC_EVENTS.AGENT_VTEAM_TRANSFERRED: + case CC_EVENTS.AGENT_CONFERENCE_TRANSFERRED: + return {type: TaskEvent.TRANSFER}; + + case CC_EVENTS.AGENT_WRAPUP: + case CC_EVENTS.AGENT_CONTACT_UNASSIGNED: + return {type: TaskEvent.WRAPUP_START}; + + case CC_EVENTS.CONTACT_ENDED: + return {type: TaskEvent.CONTACT_ENDED}; + + case CC_EVENTS.AGENT_INVITE_FAILED: + return {type: TaskEvent.INVITE_FAILED, reason: payload.data?.reason}; + + case CC_EVENTS.AGENT_CONTACT_OFFER_RONA: + return {type: TaskEvent.RONA}; + + case CC_EVENTS.CONTACT_RECORDING_PAUSED: + return {type: TaskEvent.PAUSE_RECORDING}; + + case CC_EVENTS.CONTACT_RECORDING_RESUMED: + return {type: TaskEvent.RESUME_RECORDING}; + + case CC_EVENTS.AGENT_CONSULT_CONFERENCING: + return {type: TaskEvent.START_CONFERENCE}; + + case CC_EVENTS.AGENT_CONSULT_CONFERENCED: + return {type: TaskEvent.CONFERENCE_START, participants: []}; + + case CC_EVENTS.AGENT_CONSULT_CONFERENCE_ENDED: + return {type: TaskEvent.CONFERENCE_END}; + + case CC_EVENTS.PARTICIPANT_JOINED_CONFERENCE: + return { + type: TaskEvent.PARTICIPANT_JOIN, + participant: { + id: payload.data?.participantId || '', + type: 'AGENT', + joinedAt: new Date(), + isInitiator: false, + canBeRemoved: true, + }, + }; + + case CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE: + return { + type: TaskEvent.PARTICIPANT_LEAVE, + participantId: payload.data?.participantId || '', + }; + + default: + // Not all events need state machine mapping + return null; + } + } + + /** + * Send WebSocket event to state machine if task exists + * @param ccEvent - The CC_EVENT type + * @param payload - The event payload + * @param task - The task instance + */ + private sendEventToStateMachine(ccEvent: CC_EVENTS, payload: any, task?: ITask): void { + // Check if task has state machine (will be added in Task interface) + const taskWithStateMachine = task as any; + if (!taskWithStateMachine?.stateMachine) { + return; + } + + const stateMachineEvent = this.mapWebSocketEventToStateMachineEvent(ccEvent, payload); + + if (stateMachineEvent) { + LoggerProxy.log(`Sending event to state machine: ${ccEvent} -> ${stateMachineEvent.type}`, { + module: TASK_MANAGER_FILE, + method: 'sendEventToStateMachine', + interactionId: payload.data?.interactionId, + }); + + // Send event to task's state machine + taskWithStateMachine.stateMachine.send(stateMachineEvent); + } + } + private registerTaskListeners() { this.webSocketManager.on('message', (event) => { const payload = JSON.parse(event); @@ -410,8 +542,14 @@ export default class TaskManager extends EventEmitter { default: break; } + + // Send all events to state machine after processing + // Task may have been created in AGENT_CONTACT or AGENT_CONTACT_RESERVED cases if (task) { task.emit(payload.data.type, payload.data); + + // Send event to state machine for all events + this.sendEventToStateMachine(payload.data.type, payload, task); } } }); diff --git a/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts b/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts new file mode 100644 index 00000000000..745a542a9c1 --- /dev/null +++ b/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts @@ -0,0 +1,308 @@ +/** + * Task State Machine Configuration + * + * This file defines the XState state machine configuration for contact center tasks. + * It orchestrates state transitions, guards, and actions for task lifecycle management. + */ + +import {createMachine, StateMachine} from 'xstate'; +import {TaskContext, TaskState, TaskEvent, TaskEventPayload} from './types'; +import {guards} from './guards'; +import {actions, createInitialContext} from './actions'; + +/** + * Task State Machine Configuration + * Defines all states, transitions, guards, and actions for task management + */ +export const taskStateMachineConfig = { + id: 'taskStateMachine', + initial: TaskState.IDLE, + context: createInitialContext(), + states: { + [TaskState.IDLE]: { + on: { + [TaskEvent.OFFER]: { + target: TaskState.OFFERED, + actions: ['initializeTask', 'updateState'], + }, + [TaskEvent.OFFER_CONSULT]: { + target: TaskState.OFFERED_CONSULT, + actions: ['initializeTask', 'updateState'], + }, + }, + }, + + [TaskState.OFFERED]: { + entry: ['startRonaTimer'], + exit: ['stopRonaTimer'], + on: { + [TaskEvent.ACCEPT]: { + target: TaskState.CONNECTED, + actions: ['updateState'], + }, + [TaskEvent.ASSIGN]: { + target: TaskState.CONNECTED, + actions: ['updateTaskData', 'updateState'], + }, + [TaskEvent.RONA]: { + target: TaskState.TERMINATED, + actions: ['markEnded', 'updateState'], + }, + [TaskEvent.END]: { + target: TaskState.TERMINATED, + actions: ['markEnded', 'updateState'], + }, + }, + }, + + [TaskState.OFFERED_CONSULT]: { + entry: ['startRonaTimer'], + exit: ['stopRonaTimer'], + on: { + [TaskEvent.ACCEPT]: { + target: TaskState.CONSULTING, + actions: ['updateState'], + }, + [TaskEvent.RONA]: { + target: TaskState.TERMINATED, + actions: ['markEnded', 'updateState'], + }, + [TaskEvent.END]: { + target: TaskState.TERMINATED, + actions: ['markEnded', 'updateState'], + }, + }, + }, + + [TaskState.CONNECTED]: { + on: { + [TaskEvent.HOLD]: { + target: TaskState.HELD, + cond: 'canHold', + actions: ['setHoldState', 'updateState'], + }, + [TaskEvent.CONSULT]: { + target: TaskState.CONSULTING, + cond: 'canConsult', + actions: ['setConsultInitiator', 'setConsultDestination', 'updateState'], + }, + [TaskEvent.CONSULT_CREATED]: { + target: TaskState.CONSULTING, + actions: ['updateTaskData', 'setConsultInitiator', 'updateState'], + }, + [TaskEvent.TRANSFER]: { + target: TaskState.WRAPPING_UP, + cond: 'canTransfer', + actions: ['updateState'], + }, + [TaskEvent.END]: { + target: TaskState.WRAPPING_UP, + actions: ['markEnded', 'updateState'], + }, + [TaskEvent.CONTACT_ENDED]: [ + { + target: TaskState.WRAPPING_UP, + cond: 'wrapupRequired', + actions: ['markEnded', 'updateState'], + }, + { + target: TaskState.COMPLETED, + actions: ['markEnded', 'updateState'], + }, + ], + [TaskEvent.PAUSE_RECORDING]: { + actions: ['setRecordingState'], + }, + [TaskEvent.RESUME_RECORDING]: { + actions: ['setRecordingState'], + }, + }, + }, + + [TaskState.HELD]: { + on: { + [TaskEvent.UNHOLD]: { + target: TaskState.CONNECTED, + cond: 'canResume', + actions: ['setHoldState', 'updateState'], + }, + [TaskEvent.CONSULT]: { + target: TaskState.CONSULTING, + cond: 'canConsult', + actions: ['setConsultInitiator', 'setConsultDestination', 'updateState'], + }, + [TaskEvent.TRANSFER]: { + target: TaskState.WRAPPING_UP, + cond: 'canTransfer', + actions: ['updateState'], + }, + [TaskEvent.END]: { + target: TaskState.WRAPPING_UP, + actions: ['markEnded', 'updateState'], + }, + }, + }, + + [TaskState.CONSULTING]: { + on: { + [TaskEvent.CONSULTING_ACTIVE]: { + actions: ['setConsultAgentJoined'], + }, + [TaskEvent.START_CONFERENCE]: { + target: TaskState.CONFERENCING, + cond: 'canStartConference', + actions: ['initializeConference', 'updateState'], + }, + [TaskEvent.MERGE_TO_CONFERENCE]: { + target: TaskState.CONFERENCING, + cond: 'canMergeConsultToConference', + actions: ['initializeConference', 'updateState'], + }, + [TaskEvent.CONFERENCE_START]: { + target: TaskState.CONFERENCING, + cond: 'canStartConference', + actions: ['setConferencing', 'updateState'], + }, + [TaskEvent.CONSULT_END]: { + target: TaskState.CONNECTED, + actions: ['clearConsultState', 'updateState'], + }, + [TaskEvent.CONSULT_TRANSFER]: { + target: TaskState.WRAPPING_UP, + actions: ['clearConsultState', 'updateState'], + }, + [TaskEvent.TRANSFER]: { + target: TaskState.WRAPPING_UP, + cond: 'canTransfer', + actions: ['updateState'], + }, + [TaskEvent.END]: { + target: TaskState.WRAPPING_UP, + actions: ['markEnded', 'clearConsultState', 'updateState'], + }, + [TaskEvent.CONTACT_ENDED]: [ + { + target: TaskState.WRAPPING_UP, + cond: 'wrapupRequired', + actions: ['markEnded', 'clearConsultState', 'updateState'], + }, + { + target: TaskState.COMPLETED, + actions: ['markEnded', 'clearConsultState', 'updateState'], + }, + ], + }, + }, + + [TaskState.CONFERENCING]: { + on: { + [TaskEvent.PARTICIPANT_JOIN]: { + cond: 'canAddToConference', + actions: ['addParticipant'], + }, + [TaskEvent.PARTICIPANT_LEAVE]: { + actions: ['removeParticipant'], + }, + [TaskEvent.EXIT_CONFERENCE]: { + target: TaskState.WRAPPING_UP, + cond: 'canExitConference', + actions: ['clearConferencing', 'markEnded', 'updateState'], + }, + [TaskEvent.TRANSFER_CONFERENCE]: { + target: TaskState.WRAPPING_UP, + cond: 'canTransferConference', + actions: ['clearConferencing', 'updateState'], + }, + [TaskEvent.CONFERENCE_END]: [ + { + target: TaskState.CONNECTED, + cond: 'shouldEndConference', + actions: ['clearConferencing', 'updateState'], + }, + { + target: TaskState.WRAPPING_UP, + actions: ['clearConferencing', 'markEnded', 'updateState'], + }, + ], + [TaskEvent.END]: { + target: TaskState.WRAPPING_UP, + actions: ['markEnded', 'clearConferencing', 'updateState'], + }, + [TaskEvent.CONTACT_ENDED]: [ + { + target: TaskState.WRAPPING_UP, + cond: 'wrapupRequired', + actions: ['markEnded', 'clearConferencing', 'updateState'], + }, + { + target: TaskState.COMPLETED, + actions: ['markEnded', 'clearConferencing', 'updateState'], + }, + ], + }, + }, + + [TaskState.WRAPPING_UP]: { + entry: ['startAutoWrapupTimer'], + exit: ['stopAutoWrapupTimer'], + on: { + [TaskEvent.WRAPUP]: { + target: TaskState.COMPLETED, + cond: 'canWrapup', + actions: ['updateState'], + }, + [TaskEvent.AUTO_WRAPUP]: { + target: TaskState.COMPLETED, + actions: ['updateState'], + }, + }, + }, + + [TaskState.COMPLETED]: { + type: 'final' as const, + entry: ['cleanupResources'], + }, + + [TaskState.TERMINATED]: { + type: 'final' as const, + entry: ['cleanupResources'], + }, + }, +}; + +/** + * Create a task state machine instance + * @returns StateMachine instance for task management + */ +export function createTaskStateMachine(): StateMachine< + TaskContext, + any, + TaskEventPayload, + any, + any, + any, + any +> { + return createMachine(taskStateMachineConfig, { + guards, + actions, + }); +} + +/** + * Create a task state machine with custom actions + * This allows the Task/Voice class to inject their own event emission and side effects + * @param customActions - Custom action implementations + * @returns StateMachine instance with custom actions + */ +export function createTaskStateMachineWithActions( + customActions: Record +): StateMachine { + return createMachine(taskStateMachineConfig, { + guards, + actions: { + ...actions, + ...customActions, + }, + }); +} diff --git a/packages/@webex/contact-center/src/services/task/state-machine/actions.ts b/packages/@webex/contact-center/src/services/task/state-machine/actions.ts new file mode 100644 index 00000000000..72ab2202fc6 --- /dev/null +++ b/packages/@webex/contact-center/src/services/task/state-machine/actions.ts @@ -0,0 +1,469 @@ +/** + * Task State Machine Actions + * + * Action implementations that are executed during state transitions. + * Actions modify context and can be used by the state machine to trigger side effects. + * + * NOTE: These actions are meant to be used within XState assign() or as standalone action functions. + * Event emission and UI control updates will be handled by the Task/Voice classes that use this state machine. + * + * TODO: Timer implementations (startRonaTimer, startAutoWrapupTimer) will be added to Task/Voice classes later. + * TODO: Event emission logic will be integrated with existing Task EventEmitter pattern. + * TODO: Resource cleanup logic will be added to handle WebRTC and other resources. + */ + +import {assign} from 'xstate'; +import {TaskContext, TaskState, TaskEventPayload, isEventOfType, TaskEvent} from './types'; + +/** + * Create initial context for a new task + */ +export function createInitialContext(): TaskContext { + return { + taskData: null, + currentState: TaskState.IDLE, + previousState: null, + mediaResourceId: null, + isConsulted: false, + consultInitiator: false, + consultDestination: null, + consultDestinationType: null, + consultDestinationAgentJoined: false, + consultMediaResourceId: null, + isConferencing: false, + conferenceInitiatorId: null, + conferenceParticipants: [], + maxConferenceParticipants: 10, + participants: [], // DEPRECATED: Use conferenceParticipants instead + isPrimary: false, + recordingActive: false, + recordingPaused: false, + isHold: false, + wrapUpRequired: false, + autoWrapupTimer: null, + ronaTimer: null, + offeredAt: null, + connectedAt: null, + endedAt: null, + // Action availability flags + canHold: false, + canResume: false, + canConsult: false, + canEndConsult: false, + canTransfer: false, + canWrapup: false, + }; +} + +/** + * Action implementations + * These return XState assign actions that update the context + */ +export const actions = { + /** + * Initialize task with offer data + */ + initializeTask: assign((context, event) => { + if (isEventOfType(event, TaskEvent.OFFER) || isEventOfType(event, TaskEvent.OFFER_CONSULT)) { + return { + taskData: event.taskData, + offeredAt: Date.now(), + isConsulted: event.type === TaskEvent.OFFER_CONSULT, + }; + } + + return {}; + }), + + /** + * Update task data from ASSIGN event + */ + updateTaskData: assign((context, event) => { + if (isEventOfType(event, TaskEvent.ASSIGN)) { + return { + taskData: event.taskData, + connectedAt: Date.now(), + }; + } + if (isEventOfType(event, TaskEvent.CONSULT_CREATED)) { + return { + taskData: event.taskData, + }; + } + + return {}; + }), + + /** + * Set consult initiator flag + */ + setConsultInitiator: assign({ + consultInitiator: true, + }), + + /** + * Set consult destination details + */ + setConsultDestination: assign((context, event) => { + if (isEventOfType(event, TaskEvent.CONSULT)) { + return { + consultDestination: event.destination, + consultDestinationType: event.destinationType, + isConsulted: true, + }; + } + + return {}; + }), + + /** + * Mark that consult destination agent has joined + */ + setConsultAgentJoined: assign((context, event) => { + if (isEventOfType(event, TaskEvent.CONSULTING_ACTIVE)) { + return { + consultDestinationAgentJoined: event.consultDestinationAgentJoined, + }; + } + + return {}; + }), + + /** + * Set conferencing state (legacy - kept for backward compatibility) + */ + setConferencing: assign((context, event) => { + if (isEventOfType(event, TaskEvent.CONFERENCE_START)) { + const participantIds = event.participants?.map((p) => p.id) || []; + + return { + isConferencing: true, + conferenceParticipants: event.participants || [], + participants: participantIds, + }; + } + + return {}; + }), + + /** + * Initialize conference with participants from consult + */ + initializeConference: assign((context) => { + const agentId = context.taskData?.agentId; + const consultAgent = context.consultDestination; + + if (!agentId || !consultAgent) { + return {}; + } + + return { + isConferencing: true, + conferenceInitiatorId: agentId, + conferenceParticipants: [ + { + id: agentId, + type: 'AGENT' as const, + joinedAt: new Date(), + isInitiator: true, + canBeRemoved: false, + }, + { + id: consultAgent, + type: 'AGENT' as const, + joinedAt: new Date(), + isInitiator: false, + canBeRemoved: true, + }, + ], + consultDestination: null, + consultDestinationType: null, + consultDestinationAgentJoined: false, + consultMediaResourceId: null, + }; + }), + + /** + * Add a participant to conference + */ + addParticipant: assign((context, event) => { + if (isEventOfType(event, TaskEvent.PARTICIPANT_JOIN)) { + return { + conferenceParticipants: [...context.conferenceParticipants, event.participant], + }; + } + + return {}; + }), + + /** + * Remove a participant from conference + */ + removeParticipant: assign((context, event) => { + if (isEventOfType(event, TaskEvent.PARTICIPANT_LEAVE)) { + return { + conferenceParticipants: context.conferenceParticipants.filter( + (p) => p.id !== event.participantId + ), + }; + } + + return {}; + }), + + /** + * Update conference participants (handles both JOIN and LEAVE) + */ + updateParticipants: assign((context, event) => { + if (isEventOfType(event, TaskEvent.PARTICIPANT_JOIN)) { + return { + conferenceParticipants: [...context.conferenceParticipants, event.participant], + participants: [...context.participants, event.participant.id], + }; + } + if (isEventOfType(event, TaskEvent.PARTICIPANT_LEAVE)) { + return { + conferenceParticipants: context.conferenceParticipants.filter( + (p) => p.id !== event.participantId + ), + participants: context.participants.filter((id) => id !== event.participantId), + }; + } + + return {}; + }), + + /** + * Clear conferencing state + */ + clearConferencing: assign({ + isConferencing: false, + conferenceInitiatorId: null, + conferenceParticipants: [], + participants: [], + }), + + /** + * Set hold state + */ + setHoldState: assign((context, event) => { + if (isEventOfType(event, TaskEvent.HOLD)) { + return { + isHold: true, + mediaResourceId: event.mediaResourceId, + }; + } + if (isEventOfType(event, TaskEvent.UNHOLD)) { + return { + isHold: false, + mediaResourceId: event.mediaResourceId, + }; + } + + return {}; + }), + + /** + * Set recording state + */ + setRecordingState: assign((context, event) => { + if (isEventOfType(event, TaskEvent.PAUSE_RECORDING)) { + return { + recordingPaused: true, + }; + } + if (isEventOfType(event, TaskEvent.RESUME_RECORDING)) { + return { + recordingPaused: false, + }; + } + + return {}; + }), + + /** + * Update state tracking + */ + updateState: assign((context) => { + return { + previousState: context.currentState, + }; + }), + + /** + * Mark task as ended + */ + markEnded: assign({ + endedAt: Date.now(), + }), + + /** + * Clear consult state + */ + clearConsultState: assign({ + consultDestination: null, + consultDestinationType: null, + consultDestinationAgentJoined: false, + }), + + /** + * Stop RONA timer + */ + stopRonaTimer: assign({ + ronaTimer: null, + }), + + /** + * Stop auto-wrapup timer + */ + stopAutoWrapupTimer: assign({ + autoWrapupTimer: null, + }), +}; + +/** + * Side-effect action creators + * These are functions that will be called by the state machine to perform side effects. + * They don't modify context directly, but trigger external effects like: + * - Starting timers + * - Logging + * - Emitting events (handled by Task/Voice class) + * - Cleaning up resources + */ +export const sideEffects = { + /** + * Start RONA (Ring On No Answer) timer + * This should be implemented by the caller to start an actual timer that sends RONA event after timeout + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + startRonaTimer: (context: TaskContext, event: TaskEventPayload) => { + // Implementation will be provided by Task/Voice class + // The class will start a timer and send RONA event when it expires + }, + + /** + * Start auto-wrapup timer + * Implementation provided by Task/Voice class + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + startAutoWrapupTimer: (context: TaskContext, event: TaskEventPayload) => { + // Implementation will be provided by Task/Voice class + }, + + /** + * Cleanup resources on task end + * Implementation provided by Task/Voice class to: + * - Stop timers + * - Release WebRTC resources + * - Clean up event listeners + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + cleanupResources: (context: TaskContext, event: TaskEventPayload) => { + // Implementation will be provided by Task/Voice class + }, +}; + +/** + * Helper to create action implementations that will be used by Task/Voice classes + * These factories allow the Task/Voice class to inject their own logic while keeping + * the state machine pure and testable. + */ +export interface ActionCallbacks { + onTaskIncoming?: (taskData: any) => void; + onTaskAssigned?: (taskData: any) => void; + onTaskHold?: (taskData: any) => void; + onTaskResume?: (taskData: any) => void; + onTaskConsultCreated?: (taskData: any) => void; + onTaskConsulting?: (taskData: any) => void; + onTaskConsultEnd?: (taskData: any) => void; + onTaskConferenceStarted?: (taskData: any) => void; + onTaskConferenceEnded?: (taskData: any) => void; + onTaskEnd?: (taskData: any) => void; + onTaskWrappedup?: (taskData: any) => void; + onStartRonaTimer?: (timeout: number) => number | null; + onStopRonaTimer?: (timerId: number | null) => void; + onStartAutoWrapupTimer?: (timeout: number) => number | null; + onStopAutoWrapupTimer?: (timerId: number | null) => void; + onCleanupResources?: () => void; +} + +/** + * Create action implementations with callbacks + * This allows the Task/Voice class to provide implementation for side effects + */ +export function createActionsWithCallbacks(callbacks: ActionCallbacks) { + return { + // Event emission actions + emitTaskIncoming: (context: TaskContext) => { + callbacks.onTaskIncoming?.(context.taskData); + }, + emitTaskAssigned: (context: TaskContext) => { + callbacks.onTaskAssigned?.(context.taskData); + }, + emitTaskHold: (context: TaskContext) => { + callbacks.onTaskHold?.(context.taskData); + }, + emitTaskResume: (context: TaskContext) => { + callbacks.onTaskResume?.(context.taskData); + }, + emitTaskConsultCreated: (context: TaskContext) => { + callbacks.onTaskConsultCreated?.(context.taskData); + }, + emitTaskConsulting: (context: TaskContext) => { + callbacks.onTaskConsulting?.(context.taskData); + }, + emitTaskConsultEnd: (context: TaskContext) => { + callbacks.onTaskConsultEnd?.(context.taskData); + }, + emitTaskConferenceStarted: (context: TaskContext) => { + callbacks.onTaskConferenceStarted?.(context.taskData); + }, + emitTaskConferenceEnded: (context: TaskContext) => { + callbacks.onTaskConferenceEnded?.(context.taskData); + }, + emitTaskEnd: (context: TaskContext) => { + callbacks.onTaskEnd?.(context.taskData); + }, + emitTaskWrappedup: (context: TaskContext) => { + callbacks.onTaskWrappedup?.(context.taskData); + }, + + // Timer actions + startRonaTimer: () => { + if (callbacks.onStartRonaTimer) { + const timerId = callbacks.onStartRonaTimer(30000); // 30 seconds default + if (timerId !== null) { + // Store timer ID in context via assign action + return assign({ronaTimer: timerId}); + } + } + + return undefined; + }, + stopRonaTimer: (context: TaskContext) => { + if (callbacks.onStopRonaTimer && context.ronaTimer) { + callbacks.onStopRonaTimer(context.ronaTimer); + } + }, + startAutoWrapupTimer: () => { + if (callbacks.onStartAutoWrapupTimer) { + const timerId = callbacks.onStartAutoWrapupTimer(60000); // 60 seconds default + if (timerId !== null) { + return assign({autoWrapupTimer: timerId}); + } + } + + return undefined; + }, + stopAutoWrapupTimer: (context: TaskContext) => { + if (callbacks.onStopAutoWrapupTimer && context.autoWrapupTimer) { + callbacks.onStopAutoWrapupTimer(context.autoWrapupTimer); + } + }, + + // Cleanup action + cleanupResources: () => { + callbacks.onCleanupResources?.(); + }, + }; +} diff --git a/packages/@webex/contact-center/src/services/task/state-machine/guards.ts b/packages/@webex/contact-center/src/services/task/state-machine/guards.ts new file mode 100644 index 00000000000..6b1c42efde6 --- /dev/null +++ b/packages/@webex/contact-center/src/services/task/state-machine/guards.ts @@ -0,0 +1,318 @@ +/** + * Task State Machine Guards + * + * Guard functions that determine if a state transition is allowed. + * These functions validate the current context before allowing transitions. + * + * NOTE: Guards currently only use context parameter. TaskEventPayload is imported + * for future use if guards need to inspect event data for more complex validations. + * TODO: If guards need event data in the future, add event parameter back to guard signatures. + */ + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import {TaskContext, TaskState, TaskEventPayload} from './types'; + +/** + * Guard functions for state machine transitions + */ +export const guards = { + /** + * Can accept if in OFFERED or OFFERED_CONSULT state + */ + canAccept: (context: TaskContext): boolean => { + return ( + context.currentState === TaskState.OFFERED || + context.currentState === TaskState.OFFERED_CONSULT + ); + }, + + /** + * Can only hold if connected and not already on hold + */ + canHold: (context: TaskContext): boolean => { + if (context.currentState !== TaskState.CONNECTED) { + return false; + } + + // Check if already on hold + if (context.isHold) { + return false; + } + + return true; + }, + + /** + * Can only resume if currently held + */ + canResume: (context: TaskContext): boolean => { + if (context.currentState !== TaskState.HELD) { + return false; + } + + // Must be on hold to resume + if (!context.isHold) { + return false; + } + + return true; + }, + + /** + * Can only consult if not already in consult/conference + */ + canConsult: (context: TaskContext): boolean => { + // Must be in CONNECTED or HELD state + if (context.currentState !== TaskState.CONNECTED && context.currentState !== TaskState.HELD) { + return false; + } + + // Cannot consult if already in conference + if (context.isConferencing) { + return false; + } + + return true; + }, + + /** + * Can only start conference if consult destination agent has joined + */ + canStartConference: (context: TaskContext): boolean => { + if (context.currentState !== TaskState.CONSULTING) { + return false; + } + + // Destination agent must have joined + if (!context.consultDestinationAgentJoined) { + return false; + } + + return true; + }, + + /** + * Can only transfer if not in certain states + */ + canTransfer: (context: TaskContext): boolean => { + // Can transfer from CONNECTED, HELD, or CONSULTING + return ( + context.currentState === TaskState.CONNECTED || + context.currentState === TaskState.HELD || + context.currentState === TaskState.CONSULTING + ); + }, + + /** + * Can only exit conference if actually in conference + */ + canExitConference: (context: TaskContext): boolean => { + return context.currentState === TaskState.CONFERENCING; + }, + + /** + * Can only wrapup if in WRAPPING_UP state + */ + canWrapup: (context: TaskContext): boolean => { + return context.currentState === TaskState.WRAPPING_UP; + }, + + /** + * Check if current task is from a consult offer + */ + isConsulted: (context: TaskContext): boolean => { + return context.isConsulted; + }, + + /** + * Check if conference is ending (less than 2 participants) + */ + isConferenceEnding: (context: TaskContext): boolean => { + if (context.currentState !== TaskState.CONFERENCING) { + return false; + } + + // Conference ends when fewer than 2 participants remain + return context.participants.length < 2; + }, + + /** + * Can merge consult to conference if in CONSULTING state and destination agent has joined + */ + canMergeConsultToConference: (context: TaskContext): boolean => { + return ( + context.currentState === TaskState.CONSULTING && + context.consultDestinationAgentJoined && + !context.isConferencing && + context.conferenceParticipants.length === 0 + ); + }, + + /** + * Can add participant to conference if in CONFERENCING state and not at max capacity + */ + canAddToConference: (context: TaskContext): boolean => { + return ( + context.isConferencing && + context.conferenceParticipants.length < context.maxConferenceParticipants && + context.currentState === TaskState.CONFERENCING + ); + }, + + /** + * Can transfer conference if initiator and in CONFERENCING state + * Note: event parameter would be needed to check agentId, but keeping signature consistent + */ + canTransferConference: (context: TaskContext): boolean => { + if (context.currentState !== TaskState.CONFERENCING) { + return false; + } + + // In future, we'd check if the requesting agent is the initiator via event data + // For now, check if there's an initiator set + return context.conferenceInitiatorId !== null; + }, + + /** + * Should end conference if fewer than 2 agents remain + */ + shouldEndConference: (context: TaskContext): boolean => { + const agentCount = context.conferenceParticipants.filter((p) => p.type === 'AGENT').length; + + return agentCount < 2; + }, + + /** + * Check if recording is active + */ + recordingActive: (context: TaskContext): boolean => { + return context.recordingActive && !context.recordingPaused; + }, + + /** + * Check if recording is paused + */ + recordingPaused: (context: TaskContext): boolean => { + return context.recordingActive && context.recordingPaused; + }, + + /** + * Check if wrapup is required + */ + wrapupRequired: (context: TaskContext): boolean => { + return context.wrapUpRequired; + }, + + /** + * Check if in connected state + */ + isConnected: (context: TaskContext): boolean => { + return context.currentState === TaskState.CONNECTED; + }, + + /** + * Check if in held state + */ + isHeld: (context: TaskContext): boolean => { + return context.currentState === TaskState.HELD; + }, + + /** + * Check if in consulting state + */ + isConsulting: (context: TaskContext): boolean => { + return context.currentState === TaskState.CONSULTING; + }, + + /** + * Check if in conferencing state + */ + isConferencing: (context: TaskContext): boolean => { + return context.currentState === TaskState.CONFERENCING; + }, + + /** + * Check if user is consult initiator + */ + isConsultInitiator: (context: TaskContext): boolean => { + return context.consultInitiator; + }, + + /** + * Check if interaction state is 'new' (for CONTACT_ENDED event) + */ + isInteractionStateNew: (context: TaskContext): boolean => { + if (!context.taskData || !context.taskData.interaction) { + return false; + } + + return context.taskData.interaction.state === 'new'; + }, +}; + +/** + * Helper function to check if operation is allowed in current state + * This can be used from outside the state machine + */ +export function canPerformOperation(context: TaskContext, operation: keyof typeof guards): boolean { + const guard = guards[operation]; + if (!guard) { + return false; + } + + return guard(context); +} + +/** + * Validate state transition + * Returns true if transition from current state to target state is valid + */ +export function isValidTransition(currentState: TaskState, targetState: TaskState): boolean { + // Define valid transitions matrix + const validTransitions: Record = { + [TaskState.IDLE]: [TaskState.OFFERED, TaskState.OFFERED_CONSULT], + [TaskState.OFFERED]: [TaskState.CONNECTED, TaskState.TERMINATED], + [TaskState.OFFERED_CONSULT]: [TaskState.CONSULTING, TaskState.TERMINATED], + [TaskState.CONNECTED]: [ + TaskState.HELD, + TaskState.CONSULTING, + TaskState.WRAPPING_UP, + TaskState.TERMINATED, + TaskState.CONSULT_INITIATED, // NOT IMPLEMENTED: MPC state + ], + [TaskState.HELD]: [TaskState.CONNECTED, TaskState.CONSULTING], + [TaskState.CONSULTING]: [ + TaskState.CONNECTED, + TaskState.CONFERENCING, + TaskState.WRAPPING_UP, + TaskState.TERMINATED, + TaskState.CONSULT_COMPLETED, // NOT IMPLEMENTED: MPC state + ], + [TaskState.CONFERENCING]: [ + TaskState.CONNECTED, + TaskState.WRAPPING_UP, + TaskState.TERMINATED, + TaskState.POST_CALL, // NOT IMPLEMENTED: Post-call state + ], + [TaskState.WRAPPING_UP]: [TaskState.COMPLETED], + [TaskState.COMPLETED]: [], + [TaskState.TERMINATED]: [], + // NOT IMPLEMENTED: MPC (Multi-Party Conference) states + [TaskState.CONSULT_INITIATED]: [ + TaskState.CONSULTING, + TaskState.CONSULT_COMPLETED, + TaskState.TERMINATED, + ], + [TaskState.CONSULT_COMPLETED]: [TaskState.CONNECTED, TaskState.WRAPPING_UP], + // NOT IMPLEMENTED: Post-call state + [TaskState.POST_CALL]: [TaskState.WRAPPING_UP, TaskState.COMPLETED], + // NOT IMPLEMENTED: Parked state + [TaskState.PARKED]: [TaskState.CONNECTED, TaskState.TERMINATED], + // NOT IMPLEMENTED: Monitoring state + [TaskState.MONITORING]: [TaskState.IDLE, TaskState.TERMINATED], + }; + + const allowedTargets = validTransitions[currentState] || []; + + return allowedTargets.includes(targetState); +} diff --git a/packages/@webex/contact-center/src/services/task/state-machine/index.ts b/packages/@webex/contact-center/src/services/task/state-machine/index.ts new file mode 100644 index 00000000000..9d1dff89d7b --- /dev/null +++ b/packages/@webex/contact-center/src/services/task/state-machine/index.ts @@ -0,0 +1,24 @@ +/** + * Task State Machine + * + * Export all state machine components for easy importing + */ + +// Main state machine +export { + taskStateMachineConfig, + createTaskStateMachine, + createTaskStateMachineWithActions, +} from './TaskStateMachine'; + +// Types +export {TaskState, TaskEvent, isEventOfType} from './types'; +export type {TaskContext, TaskEventPayload, TaskStateMachineConfig, UIControls} from './types'; + +// Guards +export {guards} from './guards'; +export type {TaskAction, TaskGuard} from './types'; + +// Actions +export {actions, createInitialContext, sideEffects, createActionsWithCallbacks} from './actions'; +export type {ActionCallbacks} from './actions'; diff --git a/packages/@webex/contact-center/src/services/task/state-machine/types.ts b/packages/@webex/contact-center/src/services/task/state-machine/types.ts new file mode 100644 index 00000000000..9f3ccbb01bc --- /dev/null +++ b/packages/@webex/contact-center/src/services/task/state-machine/types.ts @@ -0,0 +1,311 @@ +/** + * Task State Machine Types + * + * Type definitions for the XState-based task state machine. + * These types define states, events, context, and schemas for task lifecycle management. + */ + +import {TaskData} from '../types'; + +/** + * All possible states in the task state machine + */ +export enum TaskState { + IDLE = 'IDLE', + OFFERED = 'OFFERED', + OFFERED_CONSULT = 'OFFERED_CONSULT', + CONNECTED = 'CONNECTED', + HELD = 'HELD', + CONSULTING = 'CONSULTING', + CONFERENCING = 'CONFERENCING', + WRAPPING_UP = 'WRAPPING_UP', + COMPLETED = 'COMPLETED', + TERMINATED = 'TERMINATED', + // NOT IMPLEMENTED: MPC (Multi-Party Conference) states + CONSULT_INITIATED = 'CONSULT_INITIATED', + CONSULT_COMPLETED = 'CONSULT_COMPLETED', + // NOT IMPLEMENTED: Post-call state (isWxccPostCallEnabled feature flag) + POST_CALL = 'POST_CALL', + // NOT IMPLEMENTED: Parked state + PARKED = 'PARKED', + // NOT IMPLEMENTED: Monitoring/Supervisory states + MONITORING = 'MONITORING', +} + +/** + * All possible events that can trigger state transitions + */ +export enum TaskEvent { + // Offer events + OFFER = 'OFFER', + OFFER_CONSULT = 'OFFER_CONSULT', + + // Assignment events + ACCEPT = 'ACCEPT', + DECLINE = 'DECLINE', + ASSIGN = 'ASSIGN', + + // Hold/Resume events + HOLD = 'HOLD', + UNHOLD = 'UNHOLD', + + // Consult events + CONSULT = 'CONSULT', + CONSULT_CREATED = 'CONSULT_CREATED', + CONSULTING_ACTIVE = 'CONSULTING_ACTIVE', + CONSULT_END = 'CONSULT_END', + CONSULT_TRANSFER = 'CONSULT_TRANSFER', + CONSULT_FAILED = 'CONSULT_FAILED', + + // Conference events + START_CONFERENCE = 'START_CONFERENCE', + MERGE_TO_CONFERENCE = 'MERGE_TO_CONFERENCE', + CONFERENCE_START = 'CONFERENCE_START', + CONFERENCE_END = 'CONFERENCE_END', + TRANSFER_CONFERENCE = 'TRANSFER_CONFERENCE', + PARTICIPANT_JOIN = 'PARTICIPANT_JOIN', + PARTICIPANT_LEAVE = 'PARTICIPANT_LEAVE', + EXIT_CONFERENCE = 'EXIT_CONFERENCE', + + // Recording events + PAUSE_RECORDING = 'PAUSE_RECORDING', + RESUME_RECORDING = 'RESUME_RECORDING', + + // Transfer events + TRANSFER = 'TRANSFER', + + // Wrapup events + WRAPUP_START = 'WRAPUP_START', + WRAPUP = 'WRAPUP', + WRAPUP_COMPLETE = 'WRAPUP_COMPLETE', + + // End events + END = 'END', + RONA = 'RONA', // Ring On No Answer + CONTACT_ENDED = 'CONTACT_ENDED', + AUTO_WRAPUP = 'AUTO_WRAPUP', + + // Failure events + ASSIGN_FAILED = 'ASSIGN_FAILED', + INVITE_FAILED = 'INVITE_FAILED', + + // Queue events + CTQ_CANCEL = 'CTQ_CANCEL', // Cancel To Queue +} + +/** + * Represents a participant in a conference call + */ +export interface ConferenceParticipant { + /** Unique identifier for the participant */ + id: string; + /** Type of participant (agent, customer, or external party) */ + type: 'AGENT' | 'CUSTOMER' | 'EXTERNAL'; + /** Display name of the participant */ + name?: string; + /** Timestamp when participant joined the conference */ + joinedAt: Date; + /** Whether this participant initiated the conference */ + isInitiator: boolean; + /** Whether this participant can be removed from the conference */ + canBeRemoved: boolean; +} + +/** + * Context data maintained by the state machine + */ +export interface TaskContext { + // Task data + taskData: TaskData | null; + + // State tracking + currentState: TaskState; + previousState: TaskState | null; + + // Media tracking + mediaResourceId: string | null; + + // Consult tracking + isConsulted: boolean; + consultInitiator: boolean; + consultDestination: string | null; + consultDestinationType: 'agent' | 'queue' | 'entryPoint' | null; + consultDestinationAgentJoined: boolean; + consultMediaResourceId: string | null; + + // Conference tracking + isConferencing: boolean; + conferenceInitiatorId: string | null; + conferenceParticipants: ConferenceParticipant[]; + maxConferenceParticipants: number; + participants: string[]; // DEPRECATED: Use conferenceParticipants instead + + isPrimary: boolean; + + // Recording tracking + recordingActive: boolean; + recordingPaused: boolean; + + // Hold tracking + isHold: boolean; + + // Wrapup tracking + wrapUpRequired: boolean; + autoWrapupTimer: number | null; + + // RONA tracking + ronaTimer: number | null; + + // Timestamps + offeredAt: number | null; + connectedAt: number | null; + endedAt: number | null; + + // Action availability flags + canHold: boolean; + canResume: boolean; + canConsult: boolean; + canEndConsult: boolean; + canTransfer: boolean; + canWrapup: boolean; +} + +/** + * Event payload types for each event + */ +export type TaskEventPayload = + | {type: TaskEvent.OFFER; taskData: TaskData} + | {type: TaskEvent.OFFER_CONSULT; taskData: TaskData} + | {type: TaskEvent.ACCEPT} + | {type: TaskEvent.DECLINE} + | {type: TaskEvent.ASSIGN; taskData: TaskData} + | {type: TaskEvent.HOLD; mediaResourceId: string} + | {type: TaskEvent.UNHOLD; mediaResourceId: string} + | { + type: TaskEvent.CONSULT; + destination: string; + destinationType: 'agent' | 'queue' | 'entryPoint'; + } + | {type: TaskEvent.CONSULT_CREATED; taskData: TaskData} + | {type: TaskEvent.CONSULTING_ACTIVE; consultDestinationAgentJoined: boolean} + | {type: TaskEvent.CONSULT_END} + | {type: TaskEvent.CONSULT_TRANSFER} + | {type: TaskEvent.CONSULT_FAILED; reason?: string} + | {type: TaskEvent.START_CONFERENCE} + | {type: TaskEvent.MERGE_TO_CONFERENCE} + | {type: TaskEvent.CONFERENCE_START; participants?: ConferenceParticipant[]} + | {type: TaskEvent.CONFERENCE_END} + | {type: TaskEvent.TRANSFER_CONFERENCE; agentId?: string} + | {type: TaskEvent.PARTICIPANT_JOIN; participant: ConferenceParticipant} + | {type: TaskEvent.PARTICIPANT_LEAVE; participantId: string} + | {type: TaskEvent.EXIT_CONFERENCE; agentId?: string} + | {type: TaskEvent.PAUSE_RECORDING} + | {type: TaskEvent.RESUME_RECORDING} + | {type: TaskEvent.TRANSFER} + | {type: TaskEvent.WRAPUP_START} + | {type: TaskEvent.WRAPUP; wrapupData?: any} + | {type: TaskEvent.WRAPUP_COMPLETE} + | {type: TaskEvent.END} + | {type: TaskEvent.RONA} + | {type: TaskEvent.CONTACT_ENDED} + | {type: TaskEvent.AUTO_WRAPUP} + | {type: TaskEvent.ASSIGN_FAILED; reason?: string} + | {type: TaskEvent.INVITE_FAILED; reason?: string} + | {type: TaskEvent.CTQ_CANCEL}; + +/** + * Type guard to check event type + */ +export function isEventOfType( + event: TaskEventPayload, + type: T +): event is Extract { + return event.type === type; +} + +/** + * UI Control states derived from state machine + */ +export interface UIControls { + accept: {visible: boolean; enabled: boolean}; + decline: {visible: boolean; enabled: boolean}; + hold: {visible: boolean; enabled: boolean; label: 'Hold' | 'Resume'}; + transfer: {visible: boolean; enabled: boolean}; + consult: {visible: boolean; enabled: boolean}; + end: {visible: boolean; enabled: boolean}; + recording: {visible: boolean; enabled: boolean}; + mute: {visible: boolean; enabled: boolean}; + consultTransfer: {visible: boolean; enabled: boolean}; + endConsult: {visible: boolean; enabled: boolean}; + conference: {visible: boolean; enabled: boolean}; + exitConference: {visible: boolean; enabled: boolean}; + transferConference: {visible: boolean; enabled: boolean}; + wrapup: {visible: boolean; enabled: boolean}; +} + +/** + * State machine configuration type + */ +export interface TaskStateMachineConfig { + id: string; + initial: TaskState; + context: TaskContext; + states: Record; +} + +/** + * Action types for state machine + */ +export enum TaskAction { + // Entry/Exit actions + INITIALIZE_TASK = 'initializeTask', + START_RONA_TIMER = 'startRonaTimer', + STOP_RONA_TIMER = 'stopRonaTimer', + EMIT_TASK_INCOMING = 'emitTaskIncoming', + EMIT_TASK_ASSIGNED = 'emitTaskAssigned', + EMIT_TASK_HOLD = 'emitTaskHold', + EMIT_TASK_RESUME = 'emitTaskResume', + EMIT_TASK_CONSULT_CREATED = 'emitTaskConsultCreated', + EMIT_TASK_CONSULTING = 'emitTaskConsulting', + EMIT_TASK_CONSULT_END = 'emitTaskConsultEnd', + EMIT_TASK_CONFERENCE_STARTED = 'emitTaskConferenceStarted', + EMIT_TASK_CONFERENCE_ENDED = 'emitTaskConferenceEnded', + EMIT_TASK_END = 'emitTaskEnd', + EMIT_TASK_WRAPPEDUP = 'emitTaskWrappedup', + START_AUTO_WRAPUP_TIMER = 'startAutoWrapupTimer', + STOP_AUTO_WRAPUP_TIMER = 'stopAutoWrapupTimer', + CLEANUP_RESOURCES = 'cleanupResources', + + // Context updates + UPDATE_TASK_DATA = 'updateTaskData', + SET_CONSULT_INITIATOR = 'setConsultInitiator', + SET_CONSULT_DESTINATION = 'setConsultDestination', + SET_CONSULT_AGENT_JOINED = 'setConsultAgentJoined', + SET_CONFERENCING = 'setConferencing', + UPDATE_PARTICIPANTS = 'updateParticipants', + SET_HOLD_STATE = 'setHoldState', + SET_RECORDING_STATE = 'setRecordingState', + UPDATE_TIMESTAMP = 'updateTimestamp', +} + +/** + * Guard condition types + */ +export enum TaskGuard { + CAN_ACCEPT = 'canAccept', + CAN_HOLD = 'canHold', + CAN_RESUME = 'canResume', + CAN_CONSULT = 'canConsult', + CAN_START_CONFERENCE = 'canStartConference', + CAN_MERGE_TO_CONFERENCE = 'canMergeConsultToConference', + CAN_ADD_TO_CONFERENCE = 'canAddToConference', + CAN_TRANSFER = 'canTransfer', + CAN_EXIT_CONFERENCE = 'canExitConference', + CAN_TRANSFER_CONFERENCE = 'canTransferConference', + SHOULD_END_CONFERENCE = 'shouldEndConference', + CAN_WRAPUP = 'canWrapup', + IS_CONSULTED = 'isConsulted', + IS_CONFERENCE_ENDING = 'isConferenceEnding', + RECORDING_ACTIVE = 'recordingActive', + RECORDING_PAUSED = 'recordingPaused', +} diff --git a/packages/@webex/contact-center/src/services/task/types.ts b/packages/@webex/contact-center/src/services/task/types.ts index 16d541f7d70..d0cf8876b17 100644 --- a/packages/@webex/contact-center/src/services/task/types.ts +++ b/packages/@webex/contact-center/src/services/task/types.ts @@ -2,8 +2,10 @@ // eslint-disable-next-line import/no-unresolved import {CallId} from '@webex/calling/dist/types/common/types'; import EventEmitter from 'events'; +import {Interpreter} from 'xstate'; import {Msg} from '../core/GlobalTypes'; import AutoWrapup from './AutoWrapup'; +import {TaskContext, TaskEventPayload} from './state-machine/types'; /** * Unique identifier for a task in the contact center system @@ -1255,6 +1257,21 @@ export interface ITask extends EventEmitter { */ autoWrapup?: AutoWrapup; + /** + * State machine instance for managing task state transitions and derived properties. + * The state machine handles: + * - State transitions (IDLE → OFFERED → CONNECTED → HELD, etc.) + * - Derived properties (canHold, canResume, isConsulted, etc.) + * - Action availability based on current state + * + * This is part of the migration from manual state management to centralized state machine. + * During the transition period, both the old setUIControls() and state machine coexist. + * + * @see createTaskStateMachine + * @internal + */ + stateMachine?: Interpreter; + /** * Cancels the auto-wrapup timer for the task. * This method stops the auto-wrapup process if it is currently active. diff --git a/packages/@webex/contact-center/src/services/task/voice/Voice.ts b/packages/@webex/contact-center/src/services/task/voice/Voice.ts index d1a9cab653f..c8e233e8653 100644 --- a/packages/@webex/contact-center/src/services/task/voice/Voice.ts +++ b/packages/@webex/contact-center/src/services/task/voice/Voice.ts @@ -13,15 +13,39 @@ import { CONSULT_TRANSFER_DESTINATION_TYPE, } from '../types'; import Task from '../Task'; -import {CC_EVENTS} from '../../config/types'; import LoggerProxy from '../../../logger-proxy'; import MetricsManager from '../../../metrics/MetricsManager'; import {METRIC_EVENT_NAMES} from '../../../metrics/constants'; +import {TaskState, guards, TaskEvent} from '../state-machine'; export default class Voice extends Task implements IVoice { private isEndCallEnabled: boolean; private isEndConsultEnabled: boolean; + /** + * UI Control state constants for better readability. + * These represent [visibility, enabled] tuples used by updateTaskUiControls(). + * + * @example + * // Button is shown and clickable + * this.updateTaskUiControls({ accept: Voice.VISIBLE_ENABLED }); + * + * // Button is shown but grayed out/disabled + * this.updateTaskUiControls({ transfer: Voice.VISIBLE_DISABLED }); + * + * // Button is not displayed at all + * this.updateTaskUiControls({ consult: Voice.HIDDEN }); + */ + + /** Button is visible and enabled (clickable) - [true, true] */ + private static readonly VISIBLE_ENABLED = [true, true] as [boolean, boolean]; + + /** Button is visible but disabled (grayed out) - [true, false] */ + private static readonly VISIBLE_DISABLED = [true, false] as [boolean, boolean]; + + /** Button is hidden (not displayed) - [false, false] */ + private static readonly HIDDEN = [false, false] as [boolean, boolean]; + constructor( contact: ReturnType, data: TaskData, @@ -33,6 +57,66 @@ export default class Voice extends Task implements IVoice { this.isEndConsultEnabled = callOptions.isEndConsultEnabled ?? true; } + /** + * Helper method to create UI control state based on visibility and enabled status. + * Returns [visibility, enabled] tuple for use with updateTaskUiControls(). + * + * @param visible - Whether the button should be displayed + * @param enabled - Whether the button should be clickable (only applies if visible) + * @returns Tuple of [visibility, enabled] booleans + * + * @example + * // Dynamic control based on state + * this.updateTaskUiControls({ + * hold: this.uiControl(true, this.canPerformOperation('hold')), + * end: this.uiControl(this.isEndCallEnabled, this.isEndCallEnabled) + * }); + */ + private uiControl(visible: boolean, enabled: boolean): [boolean, boolean] { + if (!visible) return Voice.HIDDEN; + + return enabled ? Voice.VISIBLE_ENABLED : Voice.VISIBLE_DISABLED; + } + + /** + * Helper method to check if an operation is allowed in the current state + */ + private canPerformOperation(operation: string): boolean { + const context = this.stateMachineService?.state?.context; + if (!context) { + return false; + } + + switch (operation) { + case 'hold': + return guards.canHold(context); + case 'resume': + return guards.canResume(context); + case 'consult': + return guards.canConsult(context); + case 'conference': + return guards.canStartConference(context); + case 'transfer': + return guards.canTransfer(context); + case 'exitConference': + return guards.canExitConference(context); + default: + return false; + } + } + + /** + * Helper to check if consult destination agent has joined + */ + private isConsultAgentJoined(): boolean { + const context = this.stateMachineService?.state?.context; + + return context?.consultDestinationAgentJoined || false; + } + + /** + * Legacy helper for consulting controls + */ private applyConsultingControls(): void { this.updateTaskUiControls({ hold: [false, false], @@ -52,192 +136,58 @@ export default class Voice extends Task implements IVoice { } } - protected setUIControls(): void { - const eventType = this.data.type; - - switch (eventType) { - case CC_EVENTS.AGENT_CONTACT_ASSIGNED: - this.updateTaskUiControls({ - accept: [false, false], - decline: [false, false], - hold: [true, true], - transfer: [true, true], - consult: [true, true], - recording: [true, true], - end: [this.isEndCallEnabled, this.isEndCallEnabled], - endConsult: [false, false], - wrapup: [false, false], - }); - break; - - case CC_EVENTS.AGENT_WRAPUP: - case CC_EVENTS.AGENT_CONTACT_UNASSIGNED: - this.updateTaskUiControls({ - consultTransfer: [false, false], - recording: [false, false], - end: [false, false], - endConsult: [false, false], - hold: [false, false], - transfer: [false, false], - consult: [false, false], - wrapup: [true, true], - }); - break; - - case CC_EVENTS.CONTACT_ENDED: - case CC_EVENTS.AGENT_INVITE_FAILED: - this.updateTaskUiControls({ - hold: [false, false], - transfer: [false, false], - consult: [false, false], - consultTransfer: [false, false], - recording: [false, false], - end: [false, false], - endConsult: [false, false], - }); - if (this.data.interaction.state !== 'new') { - this.updateTaskUiControls({wrapup: [true, true]}); - } - break; - - case CC_EVENTS.AGENT_CONTACT_HELD: - this.updateTaskUiControls({ - hold: [true, true], - transfer: [true, true], - consult: [true, true], - recording: [true, true], - end: [this.isEndCallEnabled, false], - }); - break; - - case CC_EVENTS.AGENT_CONTACT_UNHELD: - this.updateTaskUiControls({ - hold: [true, true], - transfer: [true, true], - consult: [true, true], - recording: [true, true], - end: [this.isEndCallEnabled, true], - }); - break; - - case CC_EVENTS.AGENT_VTEAM_TRANSFERRED: - this.updateTaskUiControls({ - hold: [false, false], - transfer: [false, false], - consult: [false, false], - consultTransfer: [false, false], - recording: [false, false], - end: [false, false], - wrapup: [true, true], - }); - break; - - case CC_EVENTS.AGENT_CTQ_CANCEL_FAILED: - this.updateTaskUiControls({ - hold: [true, true], - transfer: [true, true], - consult: [true, true], - recording: [true, true], - end: [this.isEndCallEnabled, true], - }); - break; - - case CC_EVENTS.AGENT_CONSULT_CREATED: - this.updateTaskUiControls({ - hold: [false, false], - consult: [false, false], - transfer: [true, false], - end: [this.isEndCallEnabled, false], - consultTransfer: [true, false], - recording: [true, false], - endConsult: [true, true], - }); - break; - - case CC_EVENTS.AGENT_OFFER_CONSULT: - this.updateTaskUiControls({ - endConsult: [this.isEndConsultEnabled, this.isEndConsultEnabled], - }); - break; - - case CC_EVENTS.AGENT_CONSULTING: - if (!this.data.isConsulted) { - this.updateTaskUiControls({ - hold: [false, false], - transfer: [true, false], - consult: [false, false], - consultTransfer: [true, true], - recording: [true, false], - endConsult: [true, true], - end: [this.isEndCallEnabled, false], - }); - } else { - this.updateTaskUiControls({ - endConsult: [this.isEndConsultEnabled, this.isEndConsultEnabled], - }); - } - break; - - case CC_EVENTS.AGENT_CONSULT_FAILED: - case CC_EVENTS.AGENT_CONSULT_ENDED: - if (!this.data.isConsulted) { - this.updateTaskUiControls({ - hold: [true, true], - transfer: [true, true], - consult: [true, true], - recording: [true, true], - end: [this.isEndCallEnabled, this.isEndCallEnabled], - consultTransfer: [false, false], - endConsult: [false, false], - wrapup: [false, false], - }); - } else { - this.updateTaskUiControls({ - endConsult: [false, false], - }); - } - break; - - case CC_EVENTS.AGENT_CTQ_CANCELLED: - this.updateTaskUiControls({ - hold: [true, true], - transfer: [true, true], - consult: [true, true], - recording: [true, true], - end: [this.isEndCallEnabled, this.isEndCallEnabled], - consultTransfer: [false, false], - endConsult: [false, false], - wrapup: [false, false], - }); - break; - - case CC_EVENTS.AGENT_CONTACT: - if (this.data.interaction.isTerminated) { - this.updateTaskUiControls({ - hold: [false, false], - transfer: [false, false], - consult: [false, false], - consultTransfer: [false, false], - recording: [false, false], - end: [false, false], - wrapup: [true, true], - }); - } else if (this.data.interaction.state === 'connected' && !this.data.isConsulted) { - this.updateTaskUiControls({ - hold: [true, true], - transfer: [true, true], - consult: [true, true], - recording: [true, true], - end: [this.isEndCallEnabled, this.isEndCallEnabled], - }); - } else if (this.data.interaction.state === 'consulting') { - this.applyConsultingControls(); - } - break; + /** + * State-based UI control logic, driven by state machine context. + * This method derives UI control states directly from the `can*` flags + * in the state machine's context, ensuring a single source of truth. + */ + protected updateUIControlsFromState(): void { + const context = this.stateMachineService?.state?.context; + if (!context) { + // Fallback to legacy logic if state machine is not yet initialized + this.setUIControls(); - default: - break; + return; } + + const { + canHold, + canResume, + canConsult, + canEndConsult, + canTransfer, + canWrapup, + isHold, + currentState, + } = context; + + const isOffered = + currentState === TaskState.OFFERED || currentState === TaskState.OFFERED_CONSULT; + + this.updateTaskUiControls({ + accept: this.uiControl(isOffered, true), + decline: this.uiControl(isOffered, true), + hold: this.uiControl(canHold || canResume, canHold || canResume), + transfer: this.uiControl(canTransfer, canTransfer), + consult: this.uiControl(canConsult, canConsult), + endConsult: this.uiControl(canEndConsult, canEndConsult), + wrapup: this.uiControl(canWrapup, canWrapup), + end: this.uiControl(this.isEndCallEnabled, !isHold), + // Recording and conference controls can be added here as well + }); + } + + /** + * @deprecated Legacy event-based UI control logic. Kept for backward compatibility. + * This will be removed once the state machine is fully adopted. + */ + protected setUIControls(): void { + // This method is now a fallback and will be removed. + // The logic has been migrated to `updateUIControlsFromState`. + LoggerProxy.warn('Legacy setUIControls() called. This method is deprecated.', { + module: CC_FILE, + method: 'setUIControls', + }); } /** @@ -247,7 +197,51 @@ export default class Voice extends Task implements IVoice { * @throws Error */ public async accept(): Promise { - super.unsupportedMethodError(METHODS.ACCEPT); + LoggerProxy.info(`Accepting task`, { + module: CC_FILE, + method: 'accept', + interactionId: this.data.interactionId, + }); + this.metricsManager.timeEvent([ + METRIC_EVENT_NAMES.TASK_ACCEPT_SUCCESS, + METRIC_EVENT_NAMES.TASK_ACCEPT_FAILED, + ]); + try { + const response = await this.contact.accept({ + interactionId: this.data.interactionId, + }); + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.TASK_ACCEPT_SUCCESS, + { + taskId: this.data.interactionId, + mediaResourceId: this.data.mediaResourceId, + ...MetricsManager.getCommonTrackingFieldForAQMResponse(response), + }, + ['operational', 'behavioral'] + ); + LoggerProxy.log(`Task accepted successfully`, { + module: CC_FILE, + method: 'accept', + trackingId: response.trackingId, + interactionId: this.data.interactionId, + }); + this.sendStateMachineEvent({type: TaskEvent.ACCEPT}); + + return response; + } catch (error) { + const {error: detailedError} = getErrorDetails(error, 'accept', CC_FILE); + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.TASK_ACCEPT_FAILED, + { + taskId: this.data.interactionId, + mediaResourceId: this.data.mediaResourceId, + error: error.toString(), + ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed(error.details || {}), + }, + ['operational', 'behavioral'] + ); + throw detailedError; + } } /** @@ -260,6 +254,32 @@ export default class Voice extends Task implements IVoice { super.unsupportedMethodError(METHODS.REJECT); } + /** + * This is used to hold the task. + * @returns Promise + * @throws Error + * @example + * ```typescript + * task.hold().then(()=>{}).catch(()=>{}) + * ``` + * */ + public async hold(): Promise { + return this.holdResume(); + } + + /** + * This is used to resume the task. + * @returns Promise + * @throws Error + * @example + * ```typescript + * task.resume().then(()=>{}).catch(()=>{}) + * ``` + * */ + public async resume(): Promise { + return this.holdResume(); + } + /** * This is used to hold or resume the task. * @param isHeld: boolean - true to hold the task, false to resume it @@ -277,6 +297,30 @@ export default class Voice extends Task implements IVoice { */ const shouldHold = !this.data.interaction.media[this.data.mediaResourceId].isHold; + // Validate operation is allowed in current state + const context = this.stateMachineService?.state?.context; + if (context) { + if (shouldHold && !guards.canHold(context)) { + const error = new Error(`Cannot hold call in current state: ${context.currentState}`); + LoggerProxy.error('Hold operation not allowed', { + module: CC_FILE, + method: METHODS.HOLD_RESUME, + interactionId: this.data.interactionId, + }); + throw error; + } + + if (!shouldHold && !guards.canResume(context)) { + const error = new Error(`Cannot resume call in current state: ${context.currentState}`); + LoggerProxy.error('Resume operation not allowed', { + module: CC_FILE, + method: METHODS.HOLD_RESUME, + interactionId: this.data.interactionId, + }); + throw error; + } + } + LoggerProxy.info(`${shouldHold ? 'Holding' : 'Resuming'} task`, { module: CC_FILE, method: METHODS.HOLD_RESUME, @@ -367,6 +411,18 @@ export default class Voice extends Task implements IVoice { * ``` */ public async pauseRecording(): Promise { + // Validate recording is active + const context = this.stateMachineService?.state?.context; + if (context && !guards.recordingActive(context)) { + const error = new Error('Recording is not active or already paused'); + LoggerProxy.error('Pause recording operation not allowed', { + module: CC_FILE, + method: 'pauseRecording', + interactionId: this.data.interactionId, + }); + throw error; + } + try { LoggerProxy.info(`Pausing recording`, { module: CC_FILE, @@ -422,6 +478,18 @@ export default class Voice extends Task implements IVoice { public async resumeRecording( resumeRecordingPayload?: ResumeRecordingPayload ): Promise { + // Validate recording is paused + const context = this.stateMachineService?.state?.context; + if (context && !guards.recordingPaused(context)) { + const error = new Error('Recording is not paused'); + LoggerProxy.error('Resume recording operation not allowed', { + module: CC_FILE, + method: 'resumeRecording', + interactionId: this.data.interactionId, + }); + throw error; + } + try { LoggerProxy.info(`Resuming recording`, { module: CC_FILE, @@ -484,6 +552,22 @@ export default class Voice extends Task implements IVoice { * ``` * */ public async consult(consultPayload?: ConsultPayload): Promise { + // Validate consult is allowed + const context = this.stateMachineService?.state?.context; + if (context && !guards.canConsult(context)) { + const error = new Error( + `Cannot initiate consult in ${context.currentState} state${ + context.isConferencing ? ' (already in conference)' : '' + }` + ); + LoggerProxy.error('Consult operation not allowed', { + module: CC_FILE, + method: 'consult', + interactionId: this.data.interactionId, + }); + throw error; + } + try { LoggerProxy.info(`Starting consult`, { module: CC_FILE, @@ -756,6 +840,22 @@ export default class Voice extends Task implements IVoice { * @throws Error */ public async consultConference(): Promise { + // Validate conference can start + const context = this.stateMachineService?.state?.context; + if (context && !guards.canStartConference(context)) { + const error = new Error( + context.currentState !== TaskState.CONSULTING + ? 'Must be in consulting state to start conference' + : 'Consult agent has not joined yet' + ); + LoggerProxy.error('Conference operation not allowed', { + module: CC_FILE, + method: METHODS.CONSULT_CONFERENCE, + interactionId: this.data.interactionId, + }); + throw error; + } + super.unsupportedMethodError(METHODS.CONSULT_CONFERENCE); } diff --git a/packages/@webex/contact-center/test/unit/spec/services/task/voice/Voice.ts b/packages/@webex/contact-center/test/unit/spec/services/task/voice/Voice.ts index 2e7b6165fe5..45336da2e52 100644 --- a/packages/@webex/contact-center/test/unit/spec/services/task/voice/Voice.ts +++ b/packages/@webex/contact-center/test/unit/spec/services/task/voice/Voice.ts @@ -419,4 +419,66 @@ describe('Voice Task', () => { expect(ctrl.end.visible).toBe(false); }); }); + describe('state machine integration', () => { + it('should have a stateMachine property', () => { + const voice = new Voice(dummyContact, baseData, {}); + expect(voice.stateMachine).toBeDefined(); + }); + + it('should be in the initial state', () => { + const voice = new Voice(dummyContact, baseData, {}); + expect(voice.state).toBe('Idle'); + expect(voice.isRinging).toBe(false); + }); + + it('should transition to Ringing on AGENT_OFFER_CONTACT', () => { + const voice = new Voice(dummyContact, baseData, {}); + voice.updateTaskData({ + ...baseData, + type: CC_EVENTS.AGENT_OFFER_CONTACT, + } as any); + expect(voice.state).toBe('Ringing'); + expect(voice.isRinging).toBe(true); + }); + + it('should transition to Connected on AGENT_CONTACT_ASSIGNED', () => { + const voice = new Voice(dummyContact, baseData, {}); + voice.updateTaskData({ + ...baseData, + type: CC_EVENTS.AGENT_CONTACT_ASSIGNED, + } as any); + expect(voice.state).toBe('Connected'); + expect(voice.isRinging).toBe(false); + }); + + it('should transition to Held on AGENT_CONTACT_HELD', () => { + const voice = new Voice(dummyContact, baseData, {}); + voice.updateTaskData({ + ...baseData, + type: CC_EVENTS.AGENT_CONTACT_HELD, + } as any); + expect(voice.state).toBe('Held'); + expect(voice.isRinging).toBe(false); + }); + + it('should transition to Consulting on AGENT_CONSULTING', () => { + const voice = new Voice(dummyContact, baseData, {}); + voice.updateTaskData({ + ...baseData, + type: CC_EVENTS.AGENT_CONSULTING, + } as any); + expect(voice.state).toBe('Consulting'); + expect(voice.isRinging).toBe(false); + }); + + it('should transition to WrapUp on AGENT_CONTACT_UNASSIGNED', () => { + const voice = new Voice(dummyContact, baseData, {}); + voice.updateTaskData({ + ...baseData, + type: CC_EVENTS.AGENT_CONTACT_UNASSIGNED, + } as any); + expect(voice.state).toBe('WrapUp'); + expect(voice.isRinging).toBe(false); + }); + }); }); diff --git a/yarn.lock b/yarn.lock index 42871ead92a..1592f3e6bba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9055,6 +9055,7 @@ __metadata: "@webex/plugin-logger": "workspace:*" "@webex/test-helper-mock-webex": "workspace:*" "@webex/webex-core": "workspace:*" + "@xstate/inspect": ^0.8.0 eslint: ^8.24.0 eslint-config-airbnb-base: 15.0.0 eslint-config-prettier: 8.3.0 @@ -9070,6 +9071,7 @@ __metadata: prettier: 2.5.1 typedoc: ^0.25.0 typescript: 4.9.5 + xstate: ^4.38.0 languageName: unknown linkType: soft @@ -11283,6 +11285,22 @@ __metadata: languageName: node linkType: hard +"@xstate/inspect@npm:^0.8.0": + version: 0.8.0 + resolution: "@xstate/inspect@npm:0.8.0" + dependencies: + fast-safe-stringify: ^2.1.1 + peerDependencies: + "@types/ws": ^8.0.0 + ws: ^8.0.0 + xstate: ^4.37.0 + peerDependenciesMeta: + "@types/ws": + optional: true + checksum: 38a25552e3c454e05d952a226963ed19fe0028afa2393e9c1d857f225b4df01684a7edbbc3c321b6a2b25e28236e255d6d6a8ba1231b57fa891f0301c5ad1e05 + languageName: node + linkType: hard + "@xtuc/ieee754@npm:^1.2.0": version: 1.2.0 resolution: "@xtuc/ieee754@npm:1.2.0" @@ -36427,7 +36445,7 @@ __metadata: languageName: node linkType: hard -"xstate@npm:^4.30.6": +"xstate@npm:^4.30.6, xstate@npm:^4.38.0": version: 4.38.3 resolution: "xstate@npm:4.38.3" checksum: b52e5bf349834ede65b1eadf9b160b818341739b1306e882c35dd6c4ddb92f18342f534d5080c5218f935254230721faca3d34b66cbb3b6f19d8496516f23eca From d2e8d03b5772a8ee9bff3ae4d85e3924a89c60e6 Mon Sep 17 00:00:00 2001 From: arungane Date: Mon, 3 Nov 2025 16:00:26 -0500 Subject: [PATCH 03/14] fix(contact-center): update the state machine --- .../contact-center/src/services/task/Task.ts | 20 ++++++-- .../src/services/task/TaskManager.ts | 5 +- .../src/services/task/voice/Voice.ts | 48 +------------------ 3 files changed, 20 insertions(+), 53 deletions(-) diff --git a/packages/@webex/contact-center/src/services/task/Task.ts b/packages/@webex/contact-center/src/services/task/Task.ts index a4795b99fc5..65413b50c53 100644 --- a/packages/@webex/contact-center/src/services/task/Task.ts +++ b/packages/@webex/contact-center/src/services/task/Task.ts @@ -244,11 +244,21 @@ export default abstract class Task extends EventEmitter implements ITask { const machine = createTaskStateMachineWithActions(customActions); this.stateMachineService = interpret(machine) - .onTransition(() => { - LoggerProxy.log('State machine transition', { - module: CC_FILE, - method: 'onTransition', - }); + .onTransition((state) => { + // CRITICAL FIX: Sync context.currentState with XState's internal state + // The context.currentState field was not being updated, causing it to stay IDLE + // even though XState's internal state was transitioning correctly + if (state.context.currentState !== state.value) { + state.context.currentState = state.value as TaskState; + } + + LoggerProxy.log( + `State machine transition: ${state.context.previousState || 'N/A'} -> ${state.value}`, + { + module: CC_FILE, + method: 'onTransition', + } + ); // Compute derived properties after state transition const agentId = this.data.agentId; diff --git a/packages/@webex/contact-center/src/services/task/TaskManager.ts b/packages/@webex/contact-center/src/services/task/TaskManager.ts index 1a7a317928a..14bbe0c06c0 100644 --- a/packages/@webex/contact-center/src/services/task/TaskManager.ts +++ b/packages/@webex/contact-center/src/services/task/TaskManager.ts @@ -121,6 +121,7 @@ export default class TaskManager extends EventEmitter { payload.data?.interaction?.media?.[payload.data?.interactionId]?.mediaResourceId; switch (ccEvent) { + case CC_EVENTS.AGENT_CONTACT_RESERVED: case CC_EVENTS.AGENT_OFFER_CONTACT: return {type: TaskEvent.OFFER, taskData: payload.data}; @@ -219,7 +220,7 @@ export default class TaskManager extends EventEmitter { private sendEventToStateMachine(ccEvent: CC_EVENTS, payload: any, task?: ITask): void { // Check if task has state machine (will be added in Task interface) const taskWithStateMachine = task as any; - if (!taskWithStateMachine?.stateMachine) { + if (!taskWithStateMachine?.stateMachineService) { return; } @@ -233,7 +234,7 @@ export default class TaskManager extends EventEmitter { }); // Send event to task's state machine - taskWithStateMachine.stateMachine.send(stateMachineEvent); + taskWithStateMachine.stateMachineService.send(stateMachineEvent); } } diff --git a/packages/@webex/contact-center/src/services/task/voice/Voice.ts b/packages/@webex/contact-center/src/services/task/voice/Voice.ts index c8e233e8653..a70363f87de 100644 --- a/packages/@webex/contact-center/src/services/task/voice/Voice.ts +++ b/packages/@webex/contact-center/src/services/task/voice/Voice.ts @@ -16,7 +16,7 @@ import Task from '../Task'; import LoggerProxy from '../../../logger-proxy'; import MetricsManager from '../../../metrics/MetricsManager'; import {METRIC_EVENT_NAMES} from '../../../metrics/constants'; -import {TaskState, guards, TaskEvent} from '../state-machine'; +import {TaskState, guards} from '../state-machine'; export default class Voice extends Task implements IVoice { private isEndCallEnabled: boolean; @@ -197,51 +197,7 @@ export default class Voice extends Task implements IVoice { * @throws Error */ public async accept(): Promise { - LoggerProxy.info(`Accepting task`, { - module: CC_FILE, - method: 'accept', - interactionId: this.data.interactionId, - }); - this.metricsManager.timeEvent([ - METRIC_EVENT_NAMES.TASK_ACCEPT_SUCCESS, - METRIC_EVENT_NAMES.TASK_ACCEPT_FAILED, - ]); - try { - const response = await this.contact.accept({ - interactionId: this.data.interactionId, - }); - this.metricsManager.trackEvent( - METRIC_EVENT_NAMES.TASK_ACCEPT_SUCCESS, - { - taskId: this.data.interactionId, - mediaResourceId: this.data.mediaResourceId, - ...MetricsManager.getCommonTrackingFieldForAQMResponse(response), - }, - ['operational', 'behavioral'] - ); - LoggerProxy.log(`Task accepted successfully`, { - module: CC_FILE, - method: 'accept', - trackingId: response.trackingId, - interactionId: this.data.interactionId, - }); - this.sendStateMachineEvent({type: TaskEvent.ACCEPT}); - - return response; - } catch (error) { - const {error: detailedError} = getErrorDetails(error, 'accept', CC_FILE); - this.metricsManager.trackEvent( - METRIC_EVENT_NAMES.TASK_ACCEPT_FAILED, - { - taskId: this.data.interactionId, - mediaResourceId: this.data.mediaResourceId, - error: error.toString(), - ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed(error.details || {}), - }, - ['operational', 'behavioral'] - ); - throw detailedError; - } + super.unsupportedMethodError(METHODS.ACCEPT); } /** From e4af4c76d242a7b3144fbcf0b9cf948353d243bd Mon Sep 17 00:00:00 2001 From: arungane Date: Mon, 3 Nov 2025 21:37:18 -0500 Subject: [PATCH 04/14] fix(contact-center): update the guard to use the state object --- .../contact-center/src/services/task/Task.ts | 94 +++---------- .../src/services/task/state-machine/guards.ts | 132 ++++++++++++------ .../contact-center/src/services/task/types.ts | 3 +- .../src/services/task/voice/Voice.ts | 107 +++++++------- 4 files changed, 175 insertions(+), 161 deletions(-) diff --git a/packages/@webex/contact-center/src/services/task/Task.ts b/packages/@webex/contact-center/src/services/task/Task.ts index 65413b50c53..1533e512f75 100644 --- a/packages/@webex/contact-center/src/services/task/Task.ts +++ b/packages/@webex/contact-center/src/services/task/Task.ts @@ -26,64 +26,20 @@ import { TaskContext, TaskEventPayload, type ActionCallbacks, - guards, } from './state-machine'; import AutoWrapup from './AutoWrapup'; export default abstract class Task extends EventEmitter implements ITask { protected contact: ReturnType; protected metricsManager: MetricsManager; - protected stateMachineService?: Interpreter; + public stateMachineService?: Interpreter; public data: TaskData; public webCallMap: Record; public taskUiControls: TaskUIActions; + public state: any; private ronaTimerId?: NodeJS.Timeout; private autoWrapupTimerId?: NodeJS.Timeout; - /** - * State machine instance for managing task state transitions and derived properties. - * Exposed publicly to allow access to state machine context and current state. - * @internal - */ - public stateMachine?: Interpreter; - - // State machine derived properties (getters) - public get canHold(): boolean { - return this.stateMachine?.state.context.canHold ?? false; - } - - public get canResume(): boolean { - return this.stateMachine?.state.context.canResume ?? false; - } - - public get canConsult(): boolean { - return this.stateMachine?.state.context.canConsult ?? false; - } - - public get canEndConsult(): boolean { - return this.stateMachine?.state.context.canEndConsult ?? false; - } - - public get canTransfer(): boolean { - return this.stateMachine?.state.context.canTransfer ?? false; - } - - public get canWrapup(): boolean { - return this.stateMachine?.state.context.canWrapup ?? false; - } - - public get isHeld(): boolean { - return this.stateMachine?.state.matches(TaskState.HELD) ?? false; - } - - public get isConsulting(): boolean { - return this.stateMachine?.state.matches(TaskState.CONSULTING) ?? false; - } - - public get isConferencing(): boolean { - return this.stateMachine?.state.matches(TaskState.CONFERENCING) ?? false; - } - constructor(contact: ReturnType, data: TaskData) { super(); this.contact = contact; @@ -92,8 +48,6 @@ export default abstract class Task extends EventEmitter implements ITask { this.webCallMap = {}; this.initialiseUIControls(); this.initializeStateMachine(); - // Expose stateMachineService as public stateMachine for ITask interface compliance - this.stateMachine = this.stateMachineService; } // Properties from ITask interface @@ -245,13 +199,6 @@ export default abstract class Task extends EventEmitter implements ITask { this.stateMachineService = interpret(machine) .onTransition((state) => { - // CRITICAL FIX: Sync context.currentState with XState's internal state - // The context.currentState field was not being updated, causing it to stay IDLE - // even though XState's internal state was transitioning correctly - if (state.context.currentState !== state.value) { - state.context.currentState = state.value as TaskState; - } - LoggerProxy.log( `State machine transition: ${state.context.previousState || 'N/A'} -> ${state.value}`, { @@ -259,7 +206,7 @@ export default abstract class Task extends EventEmitter implements ITask { method: 'onTransition', } ); - + this.state = state; // Compute derived properties after state transition const agentId = this.data.agentId; if (agentId) { @@ -270,9 +217,6 @@ export default abstract class Task extends EventEmitter implements ITask { this.updateUIControlsFromState(); }) .start(); - - // Expose as public property for ITask interface - this.stateMachine = this.stateMachineService; } /** @@ -373,7 +317,6 @@ export default abstract class Task extends EventEmitter implements ITask { if (this.stateMachineService) { this.stateMachineService.stop(); this.stateMachineService = undefined; - this.stateMachine = undefined; } } @@ -445,22 +388,24 @@ export default abstract class Task extends EventEmitter implements ITask { * Called whenever task data is updated or state transitions occur */ protected computeDerivedProperties(agentId: string): void { - const context = this.stateMachineService?.state?.context; - if (!context) return; + const state = this.stateMachineService?.state; + if (!state) return; + + const {context} = state; try { // Compute consultStatus - this.data.consultStatus = this.getConsultStatusFromContext(context, agentId); + this.data.consultStatus = this.getConsultStatus(agentId); // Compute isConsultInProgress - this.data.isConsultInProgress = guards.isConsulting(context); + this.data.isConsultInProgress = state.matches(TaskState.CONSULTING); // Compute isOnHold - this.data.isOnHold = guards.isHeld(context); + this.data.isOnHold = state.matches(TaskState.HELD); // Compute isConferenceInProgress (already exists but ensure consistency) this.data.isConferenceInProgress = - guards.isConferencing(context) && context.participants.length >= 2; + state.matches(TaskState.CONFERENCING) && context.participants.length >= 2; // Compute isCustomerInCall this.data.isCustomerInCall = this.checkCustomerInCall(); @@ -487,28 +432,29 @@ export default abstract class Task extends EventEmitter implements ITask { } /** - * Get consultation status from state machine context + * Get consultation status from state machine */ - private getConsultStatusFromContext(context: TaskContext, agentId: string): string { - const state = context.currentState; + private getConsultStatus(agentId: string): string { + const state = this.stateMachineService?.state; + if (!state) return 'NONE'; const participants = this.data.interaction?.participants || {}; const participant: any = Object.values(participants).find( (p: any) => p.pType === 'Agent' && p.id === agentId ); - if (state === TaskState.CONSULT_INITIATED) { + if (state.matches(TaskState.CONSULT_INITIATED)) { return participant?.isConsulted ? 'BEING_CONSULTED' : 'CONSULT_INITIATED'; } - if (state === TaskState.CONSULTING) { + if (state.matches(TaskState.CONSULTING)) { return participant?.isConsulted ? 'BEING_CONSULTED_ACCEPTED' : 'CONSULT_ACCEPTED'; } - if (state === TaskState.CONNECTED) { + if (state.matches(TaskState.CONNECTED)) { return 'CONNECTED'; } - if (state === TaskState.CONFERENCING) { + if (state.matches(TaskState.CONFERENCING)) { return 'CONFERENCE'; } - if (state === TaskState.CONSULT_COMPLETED) { + if (state.matches(TaskState.CONSULT_COMPLETED)) { return 'CONSULT_COMPLETED'; } diff --git a/packages/@webex/contact-center/src/services/task/state-machine/guards.ts b/packages/@webex/contact-center/src/services/task/state-machine/guards.ts index 6b1c42efde6..8c1e961719c 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/guards.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/guards.ts @@ -9,6 +9,7 @@ * TODO: If guards need event data in the future, add event parameter back to guard signatures. */ +import {StateValue} from 'xstate'; // eslint-disable-next-line @typescript-eslint/no-unused-vars import {TaskContext, TaskState, TaskEventPayload} from './types'; @@ -19,18 +20,18 @@ export const guards = { /** * Can accept if in OFFERED or OFFERED_CONSULT state */ - canAccept: (context: TaskContext): boolean => { - return ( - context.currentState === TaskState.OFFERED || - context.currentState === TaskState.OFFERED_CONSULT - ); + canAccept: (context: TaskContext, event: any, meta: {state: {value: StateValue}}): boolean => { + const state = meta.state.value as TaskState; + + return state === TaskState.OFFERED || state === TaskState.OFFERED_CONSULT; }, /** * Can only hold if connected and not already on hold */ - canHold: (context: TaskContext): boolean => { - if (context.currentState !== TaskState.CONNECTED) { + canHold: (context: TaskContext, event: any, meta: {state: {value: StateValue}}): boolean => { + const state = meta.state.value as TaskState; + if (state !== TaskState.CONNECTED) { return false; } @@ -45,8 +46,9 @@ export const guards = { /** * Can only resume if currently held */ - canResume: (context: TaskContext): boolean => { - if (context.currentState !== TaskState.HELD) { + canResume: (context: TaskContext, event: any, meta: {state: {value: StateValue}}): boolean => { + const state = meta.state.value as TaskState; + if (state !== TaskState.HELD) { return false; } @@ -61,9 +63,10 @@ export const guards = { /** * Can only consult if not already in consult/conference */ - canConsult: (context: TaskContext): boolean => { + canConsult: (context: TaskContext, event: any, meta: {state: {value: StateValue}}): boolean => { + const state = meta.state.value as TaskState; // Must be in CONNECTED or HELD state - if (context.currentState !== TaskState.CONNECTED && context.currentState !== TaskState.HELD) { + if (state !== TaskState.CONNECTED && state !== TaskState.HELD) { return false; } @@ -78,8 +81,13 @@ export const guards = { /** * Can only start conference if consult destination agent has joined */ - canStartConference: (context: TaskContext): boolean => { - if (context.currentState !== TaskState.CONSULTING) { + canStartConference: ( + context: TaskContext, + event: any, + meta: {state: {value: StateValue}} + ): boolean => { + const state = meta.state.value as TaskState; + if (state !== TaskState.CONSULTING) { return false; } @@ -94,27 +102,35 @@ export const guards = { /** * Can only transfer if not in certain states */ - canTransfer: (context: TaskContext): boolean => { + canTransfer: (context: TaskContext, event: any, meta: {state: {value: StateValue}}): boolean => { + const state = meta.state.value as TaskState; // Can transfer from CONNECTED, HELD, or CONSULTING + return ( - context.currentState === TaskState.CONNECTED || - context.currentState === TaskState.HELD || - context.currentState === TaskState.CONSULTING + state === TaskState.CONNECTED || state === TaskState.HELD || state === TaskState.CONSULTING ); }, /** * Can only exit conference if actually in conference */ - canExitConference: (context: TaskContext): boolean => { - return context.currentState === TaskState.CONFERENCING; + canExitConference: ( + context: TaskContext, + event: any, + meta: {state: {value: StateValue}} + ): boolean => { + const state = meta.state.value as TaskState; + + return state === TaskState.CONFERENCING; }, /** * Can only wrapup if in WRAPPING_UP state */ - canWrapup: (context: TaskContext): boolean => { - return context.currentState === TaskState.WRAPPING_UP; + canWrapup: (context: TaskContext, event: any, meta: {state: {value: StateValue}}): boolean => { + const state = meta.state.value as TaskState; + + return state === TaskState.WRAPPING_UP; }, /** @@ -127,8 +143,13 @@ export const guards = { /** * Check if conference is ending (less than 2 participants) */ - isConferenceEnding: (context: TaskContext): boolean => { - if (context.currentState !== TaskState.CONFERENCING) { + isConferenceEnding: ( + context: TaskContext, + event: any, + meta: {state: {value: StateValue}} + ): boolean => { + const state = meta.state.value as TaskState; + if (state !== TaskState.CONFERENCING) { return false; } @@ -139,9 +160,15 @@ export const guards = { /** * Can merge consult to conference if in CONSULTING state and destination agent has joined */ - canMergeConsultToConference: (context: TaskContext): boolean => { + canMergeConsultToConference: ( + context: TaskContext, + event: any, + meta: {state: {value: StateValue}} + ): boolean => { + const state = meta.state.value as TaskState; + return ( - context.currentState === TaskState.CONSULTING && + state === TaskState.CONSULTING && context.consultDestinationAgentJoined && !context.isConferencing && context.conferenceParticipants.length === 0 @@ -151,11 +178,17 @@ export const guards = { /** * Can add participant to conference if in CONFERENCING state and not at max capacity */ - canAddToConference: (context: TaskContext): boolean => { + canAddToConference: ( + context: TaskContext, + event: any, + meta: {state: {value: StateValue}} + ): boolean => { + const state = meta.state.value as TaskState; + return ( context.isConferencing && context.conferenceParticipants.length < context.maxConferenceParticipants && - context.currentState === TaskState.CONFERENCING + state === TaskState.CONFERENCING ); }, @@ -163,8 +196,13 @@ export const guards = { * Can transfer conference if initiator and in CONFERENCING state * Note: event parameter would be needed to check agentId, but keeping signature consistent */ - canTransferConference: (context: TaskContext): boolean => { - if (context.currentState !== TaskState.CONFERENCING) { + canTransferConference: ( + context: TaskContext, + event: any, + meta: {state: {value: StateValue}} + ): boolean => { + const state = meta.state.value as TaskState; + if (state !== TaskState.CONFERENCING) { return false; } @@ -206,29 +244,41 @@ export const guards = { /** * Check if in connected state */ - isConnected: (context: TaskContext): boolean => { - return context.currentState === TaskState.CONNECTED; + isConnected: (context: TaskContext, event: any, meta: {state: {value: StateValue}}): boolean => { + const state = meta.state.value as TaskState; + + return state === TaskState.CONNECTED; }, /** * Check if in held state */ - isHeld: (context: TaskContext): boolean => { - return context.currentState === TaskState.HELD; + isHeld: (context: TaskContext, event: any, meta: {state: {value: StateValue}}): boolean => { + const state = meta.state.value as TaskState; + + return state === TaskState.HELD; }, /** * Check if in consulting state */ - isConsulting: (context: TaskContext): boolean => { - return context.currentState === TaskState.CONSULTING; + isConsulting: (context: TaskContext, event: any, meta: {state: {value: StateValue}}): boolean => { + const state = meta.state.value as TaskState; + + return state === TaskState.CONSULTING; }, /** * Check if in conferencing state */ - isConferencing: (context: TaskContext): boolean => { - return context.currentState === TaskState.CONFERENCING; + isConferencing: ( + context: TaskContext, + event: any, + meta: {state: {value: StateValue}} + ): boolean => { + const state = meta.state.value as TaskState; + + return state === TaskState.CONFERENCING; }, /** @@ -254,13 +304,17 @@ export const guards = { * Helper function to check if operation is allowed in current state * This can be used from outside the state machine */ -export function canPerformOperation(context: TaskContext, operation: keyof typeof guards): boolean { +export function canPerformOperation( + context: TaskContext, + operation: keyof typeof guards, + state: {value: StateValue} +): boolean { const guard = guards[operation]; if (!guard) { return false; } - return guard(context); + return guard(context, null, {state}); } /** diff --git a/packages/@webex/contact-center/src/services/task/types.ts b/packages/@webex/contact-center/src/services/task/types.ts index d0cf8876b17..1bad464d8fb 100644 --- a/packages/@webex/contact-center/src/services/task/types.ts +++ b/packages/@webex/contact-center/src/services/task/types.ts @@ -1270,7 +1270,8 @@ export interface ITask extends EventEmitter { * @see createTaskStateMachine * @internal */ - stateMachine?: Interpreter; + stateMachineService?: Interpreter; + state?: any; /** * Cancels the auto-wrapup timer for the task. diff --git a/packages/@webex/contact-center/src/services/task/voice/Voice.ts b/packages/@webex/contact-center/src/services/task/voice/Voice.ts index a70363f87de..2db1591ef42 100644 --- a/packages/@webex/contact-center/src/services/task/voice/Voice.ts +++ b/packages/@webex/contact-center/src/services/task/voice/Voice.ts @@ -82,24 +82,32 @@ export default class Voice extends Task implements IVoice { * Helper method to check if an operation is allowed in the current state */ private canPerformOperation(operation: string): boolean { - const context = this.stateMachineService?.state?.context; - if (!context) { + const state = this.stateMachineService?.state; + if (!state) { return false; } switch (operation) { case 'hold': - return guards.canHold(context); + return state.matches(TaskState.CONNECTED) && !state.context.isHold; case 'resume': - return guards.canResume(context); + return state.matches(TaskState.HELD) && state.context.isHold; case 'consult': - return guards.canConsult(context); + return ( + (state.matches(TaskState.CONNECTED) || state.matches(TaskState.HELD)) && + !state.context.isConsulted && + !state.context.isConferencing + ); case 'conference': - return guards.canStartConference(context); + return state.matches(TaskState.CONSULTING) && state.context.consultDestinationAgentJoined; case 'transfer': - return guards.canTransfer(context); + return ( + state.matches(TaskState.CONNECTED) || + state.matches(TaskState.HELD) || + state.matches(TaskState.CONSULTING) + ); case 'exitConference': - return guards.canExitConference(context); + return state.matches(TaskState.CONFERENCING); default: return false; } @@ -142,27 +150,18 @@ export default class Voice extends Task implements IVoice { * in the state machine's context, ensuring a single source of truth. */ protected updateUIControlsFromState(): void { - const context = this.stateMachineService?.state?.context; - if (!context) { + const state = this.stateMachineService?.state; + if (!state) { // Fallback to legacy logic if state machine is not yet initialized this.setUIControls(); return; } - const { - canHold, - canResume, - canConsult, - canEndConsult, - canTransfer, - canWrapup, - isHold, - currentState, - } = context; + const {context} = state; + const {canHold, canResume, canConsult, canEndConsult, canTransfer, canWrapup, isHold} = context; - const isOffered = - currentState === TaskState.OFFERED || currentState === TaskState.OFFERED_CONSULT; + const isOffered = state.matches(TaskState.OFFERED) || state.matches(TaskState.OFFERED_CONSULT); this.updateTaskUiControls({ accept: this.uiControl(isOffered, true), @@ -254,20 +253,21 @@ export default class Voice extends Task implements IVoice { const shouldHold = !this.data.interaction.media[this.data.mediaResourceId].isHold; // Validate operation is allowed in current state - const context = this.stateMachineService?.state?.context; - if (context) { - if (shouldHold && !guards.canHold(context)) { - const error = new Error(`Cannot hold call in current state: ${context.currentState}`); - LoggerProxy.error('Hold operation not allowed', { - module: CC_FILE, - method: METHODS.HOLD_RESUME, - interactionId: this.data.interactionId, - }); - throw error; - } - - if (!shouldHold && !guards.canResume(context)) { - const error = new Error(`Cannot resume call in current state: ${context.currentState}`); + const state = this.stateMachineService?.state; + if (state) { + const currentState = state.value as TaskState; + if (shouldHold) { + if (!state.matches(TaskState.CONNECTED) || state.context.isHold) { + const error = new Error(`Cannot hold call in current state: ${currentState}`); + LoggerProxy.error('Hold operation not allowed', { + module: CC_FILE, + method: METHODS.HOLD_RESUME, + interactionId: this.data.interactionId, + }); + throw error; + } + } else if (!state.matches(TaskState.HELD) || !state.context.isHold) { + const error = new Error(`Cannot resume call in current state: ${currentState}`); LoggerProxy.error('Resume operation not allowed', { module: CC_FILE, method: METHODS.HOLD_RESUME, @@ -509,11 +509,18 @@ export default class Voice extends Task implements IVoice { * */ public async consult(consultPayload?: ConsultPayload): Promise { // Validate consult is allowed - const context = this.stateMachineService?.state?.context; - if (context && !guards.canConsult(context)) { + const state = this.stateMachineService?.state; + const canConsult = + state && + (state.matches(TaskState.CONNECTED) || state.matches(TaskState.HELD)) && + !state.context.isConsulted && + !state.context.isConferencing; + + if (!canConsult) { + const currentState = state?.value as TaskState; const error = new Error( - `Cannot initiate consult in ${context.currentState} state${ - context.isConferencing ? ' (already in conference)' : '' + `Cannot initiate consult in ${currentState} state${ + state?.context.isConferencing ? ' (already in conference)' : '' }` ); LoggerProxy.error('Consult operation not allowed', { @@ -797,13 +804,19 @@ export default class Voice extends Task implements IVoice { */ public async consultConference(): Promise { // Validate conference can start - const context = this.stateMachineService?.state?.context; - if (context && !guards.canStartConference(context)) { - const error = new Error( - context.currentState !== TaskState.CONSULTING - ? 'Must be in consulting state to start conference' - : 'Consult agent has not joined yet' - ); + const state = this.stateMachineService?.state; + if (!state || !state.matches(TaskState.CONSULTING)) { + const error = new Error('Must be in consulting state to start conference'); + LoggerProxy.error('Conference operation not allowed', { + module: CC_FILE, + method: METHODS.CONSULT_CONFERENCE, + interactionId: this.data.interactionId, + }); + throw error; + } + + if (!state.context.consultDestinationAgentJoined) { + const error = new Error('Consult agent has not joined yet'); LoggerProxy.error('Conference operation not allowed', { module: CC_FILE, method: METHODS.CONSULT_CONFERENCE, From 95afdde761d39ba642ddb6209a0c08b4eff31f6a Mon Sep 17 00:00:00 2001 From: arungane Date: Tue, 4 Nov 2025 21:31:37 -0500 Subject: [PATCH 05/14] fix(contact-center): finalize the state machine --- .../contact-center/src/services/task/Task.ts | 907 +++++++++++++++--- .../services/task/state-machine/actions.ts | 17 - .../src/services/task/state-machine/guards.ts | 47 +- .../src/services/task/state-machine/types.ts | 27 +- .../contact-center/src/services/task/types.ts | 5 + .../src/services/task/voice/Voice.ts | 418 ++++++-- 6 files changed, 1126 insertions(+), 295 deletions(-) diff --git a/packages/@webex/contact-center/src/services/task/Task.ts b/packages/@webex/contact-center/src/services/task/Task.ts index 1533e512f75..8f0135bcb0f 100644 --- a/packages/@webex/contact-center/src/services/task/Task.ts +++ b/packages/@webex/contact-center/src/services/task/Task.ts @@ -29,24 +29,210 @@ import { } from './state-machine'; import AutoWrapup from './AutoWrapup'; +const PARTICIPANT_TYPE_AGENT = 'Agent'; +const PARTICIPANT_TYPE_CUSTOMER = 'Customer'; +const PARTICIPANT_TYPE_SUPERVISOR = 'Supervisor'; +const PARTICIPANT_TYPE_VVA = 'VVA'; + +const EXCLUDED_PARTICIPANT_TYPES = [ + PARTICIPANT_TYPE_AGENT, + PARTICIPANT_TYPE_CUSTOMER, + PARTICIPANT_TYPE_SUPERVISOR, + PARTICIPANT_TYPE_VVA, +]; + +const MEDIA_TYPE_CONSULT = 'consult'; +const MEDIA_TYPE_TELEPHONY = 'telephony'; + +const INTERACTION_STATE_NEW = 'new'; +const INTERACTION_STATE_CONSULT = 'consult'; +const INTERACTION_STATE_CONNECTED = 'connected'; +const INTERACTION_STATE_CONFERENCE = 'conference'; +const INTERACTION_STATE_WRAPUP = 'wrapup'; +const INTERACTION_STATE_POST_CALL = 'post_call'; + +const CONSULT_STATE_INITIATED = 'INITIATED'; +const CONSULT_STATE_COMPLETED = 'COMPLETED'; +const CONSULT_STATE_CONFERENCING = 'CONFERENCING'; + +const RELATIONSHIP_TYPE_CONSULT = 'CONSULT'; + +const TASK_STATE_CONSULT = 'consult'; +const TASK_STATE_CONSULTING = 'consulting'; +const TASK_STATE_CONSULT_COMPLETED = 'consult_completed'; + +/** + * Participant information for UI display + */ +export type Participant = { + id: string; + name?: string; + pType?: string; +}; + +/** + * Immutable task properties computed once at task creation. + * These properties don't change throughout the task lifecycle. + */ +export interface TaskImmutableProps { + /** Unique interaction identifier */ + readonly interactionId: string | null; + /** Media type (telephony, chat, email) */ + readonly mediaType: string | null; + /** Media channel identifier */ + readonly mediaChannel: string | null; + /** True if this is a telephony task */ + readonly isCall: boolean; + /** True if this is a chat task */ + readonly isChat: boolean; + /** True if this is an email task */ + readonly isEmail: boolean; + /** True if this is a digital channel (chat or email) */ + readonly isDigitalChannel: boolean; + /** True if agent is the secondary agent in consult/conference */ + readonly isSecondaryAgent: boolean; + /** True if agent is secondary EP/DN agent */ + readonly isSecondaryEpDnAgent: boolean; + /** Timestamp when agent joined the interaction */ + readonly agentJoinTimestamp: number | null; +} + +/** + * Dynamic task properties computed on-demand from current task state. + * These properties reflect the current state and can change as events occur. + */ +export interface TaskDynamicProps { + /** Full interaction data object */ + readonly interaction: TaskData['interaction'] | null; + /** Call-specific details */ + readonly callDetails: Record | null; + /** Current consultation status */ + readonly consultStatus: string | null; + /** True if consultation is in progress */ + readonly isConsultInProgress: boolean; + /** True if main call is on hold */ + readonly isOnHold: boolean; + /** Alias for isOnHold */ + readonly isHeld: boolean; + /** True if consult call is on hold */ + readonly consultCallHeld: boolean; + /** True if conference is active */ + readonly isConferenceInProgress: boolean; + /** Number of conference participants */ + readonly conferenceParticipantsCount: number; + /** List of conference participants */ + readonly conferenceParticipants: Participant[]; + /** True if customer is still in the call */ + readonly isCustomerInCall: boolean; + /** Current MPC (Multi-Party Conference) state */ + readonly mpcState: string | null; + /** Information about the consulting agent */ + readonly consultingAgent: Participant | null; + /** Remaining auto-wrapup time in seconds */ + readonly autoWrapupSeconds: number | null; + /** True if auto-wrapup can be cancelled */ + readonly canCancelAutoWrapup: boolean; + /** True if consult has been initiated */ + readonly isConsultInitiated: boolean; + /** True if consult has been accepted */ + readonly isConsultAccepted: boolean; + /** True if this agent is being consulted */ + readonly isBeingConsulted: boolean; + /** True if consult has completed */ + readonly isConsultCompleted: boolean; + /** True if consult is initiated or accepted */ + readonly isConsultInitiatedOrAccepted: boolean; + /** True if consult is initiated or accepted (not being consulted) */ + readonly isConsultInitiatedOrAcceptedOnly: boolean; + /** True if consult is initiated, accepted, or being consulted */ + readonly isConsultInitiatedOrAcceptedOrBeingConsulted: boolean; + /** True if this agent received a consult request */ + readonly isConsultReceived: boolean; + /** True if consult is both initiated and accepted */ + readonly isConsultInitiatedAndAccepted: boolean; + /** True if this is an incoming task for the agent */ + readonly isIncomingTask: boolean; +} + +/** + * UI control state for a single task action button. + * Represents visibility and enabled state for UI components. + */ +export interface UIControlState { + /** Whether the button should be displayed */ + visible: boolean; + /** Whether the button should be clickable (only applies if visible) */ + enabled: boolean; +} + +/** + * UI controls for all task actions. + * Computed from state machine state and context. + */ +export interface TaskUIControls { + accept: UIControlState; + decline: UIControlState; + hold: UIControlState; + mute: UIControlState; + end: UIControlState; + transfer: UIControlState; + consult: UIControlState; + consultTransfer: UIControlState; + endConsult: UIControlState; + recording: UIControlState; + conference: UIControlState; + wrapup: UIControlState; + exitConference: UIControlState; + transferConference: UIControlState; + mergeToConference: UIControlState; +} + +/** + * Combined task properties for UI components. + * Provides convenient access to both immutable and dynamic task properties, + * plus computed UI controls based on state machine state. + */ +export interface TaskDerivedState extends TaskImmutableProps, TaskDynamicProps { + /** UI controls computed from state machine state */ + uiControls: TaskUIControls; +} + +/** + * @deprecated Use TaskDerivedState instead + */ +export type TaskAccessor = TaskDerivedState; + +/** + * @deprecated Use Participant instead + */ +export type TaskAccessorParticipant = Participant; + export default abstract class Task extends EventEmitter implements ITask { protected contact: ReturnType; protected metricsManager: MetricsManager; public stateMachineService?: Interpreter; public data: TaskData; public webCallMap: Record; - public taskUiControls: TaskUIActions; public state: any; private ronaTimerId?: NodeJS.Timeout; private autoWrapupTimerId?: NodeJS.Timeout; + /** + * Immutable task properties computed once at construction. + * These values don't change throughout the task lifecycle. + */ + private readonly immutableProps: TaskImmutableProps; + constructor(contact: ReturnType, data: TaskData) { super(); this.contact = contact; this.data = data; this.metricsManager = MetricsManager.getInstance(); this.webCallMap = {}; - this.initialiseUIControls(); + if (this.data?.agentId) { + this.data.isIncomingTask = this.isIncomingTask(this.data.agentId); + } + this.immutableProps = this.computeImmutableProps(); this.initializeStateMachine(); } @@ -154,6 +340,171 @@ export default abstract class Task extends EventEmitter implements ITask { return Promise.reject(new Error('holdResume not supported for this channel type')); } + /** + * Get computed task state for UI components. + * Combines immutable properties (computed once) with dynamic properties + * (computed fresh from current state) and UI controls (computed from state machine). + * + * @returns Combined immutable and dynamic task properties plus UI controls + * + * @example + * ```typescript + * // Access immutable properties + * const mediaType = task.derived.mediaType; + * const isCall = task.derived.isCall; + * + * // Access dynamic properties (always fresh) + * if (task.derived.isConsultInProgress) { + * showConsultUI(); + * } + * + * // Access UI controls (computed from state machine) + * + * ``` + */ + public get derived(): TaskDerivedState { + return { + ...this.immutableProps, + ...this.computeDynamicProps(), + uiControls: this.computeUIControls(), + }; + } + + /** + * Backward compatibility getter for taskUiControls. + * @deprecated Use task.derived.uiControls instead + * This provides the same data but computed fresh from state machine state. + * + * @example + * ```typescript + * // Old way (deprecated) + * const visible = task.taskUiControls.hold.visible; + * + * // New way (recommended) + * const visible = task.derived.uiControls.hold.visible; + * ``` + */ + public get taskUiControls(): TaskUIActions { + // Convert computed UI controls to TaskActionControl objects for backward compatibility + const controls = this.computeUIControls(); + const result: any = {}; + + Object.keys(controls).forEach((key) => { + const control = controls[key as keyof TaskUIControls]; + result[key] = new TaskButtonControl(control.visible, control.enabled); + }); + + return result as TaskUIActions; + } + + /** + * @deprecated Use `derived` instead for better clarity + */ + public get accessor(): TaskDerivedState { + return this.derived; + } + + /** + * Compute immutable properties once at task creation. + * These properties are based on initial task data and don't change. + */ + private computeImmutableProps(): TaskImmutableProps { + const interaction = this.data?.interaction ?? null; + const agentId = this.data?.agentId; + const mediaType = interaction?.mediaType ?? null; + const isCall = mediaType === MEDIA_TYPE_TELEPHONY; + const isChat = mediaType === 'chat'; + const isEmail = mediaType === 'email'; + const isDigitalChannel = Boolean(isChat || isEmail); + + return { + interactionId: this.data?.interactionId ?? null, + mediaType, + mediaChannel: interaction?.mediaChannel ?? null, + isCall, + isChat, + isEmail, + isDigitalChannel, + isSecondaryAgent: this.isSecondaryAgent(), + isSecondaryEpDnAgent: this.isSecondaryEpDnAgent(), + agentJoinTimestamp: agentId ? this.getAgentJoinTimestamp(agentId) : null, + }; + } + + /** + * Compute dynamic properties that can change as task state evolves. + * These are computed fresh on each access to reflect current state. + * + * HYBRID APPROACH: + * - Simple boolean flags (isOnHold, isConsultInProgress, isConferenceInProgress) + * are read from state machine context for consistency + * - Complex computed properties (participants lists, timestamps, etc.) + * are computed from this.data as before + */ + private computeDynamicProps(): TaskDynamicProps { + const agentId = this.data?.agentId; + + const consultStatus = agentId + ? this.getConsultStatus(agentId) + : this.data?.consultStatus ?? null; + const isConsultInitiated = consultStatus === 'CONSULT_INITIATED'; + const isConsultAccepted = consultStatus === 'CONSULT_ACCEPTED'; + const isBeingConsulted = + consultStatus === 'BEING_CONSULTED' || consultStatus === 'BEING_CONSULTED_ACCEPTED'; + const isConsultCompleted = consultStatus === 'CONSULT_COMPLETED'; + const isConsultInitiatedOrAccepted = + isConsultInitiated || isConsultAccepted || isBeingConsulted; + const isConsultInitiatedOrAcceptedOnly = isConsultInitiated || isConsultAccepted; + const isConsultInitiatedOrAcceptedOrBeingConsulted = + isConsultInitiated || isConsultAccepted || isBeingConsulted; + const isConsultReceived = isBeingConsulted; + const isConsultInitiatedAndAccepted = isConsultAccepted; + + // Derive state flags from state machine state + const state = this.stateMachineService?.state; + const isConsultInProgress = + state?.matches(TaskState.CONSULTING) ?? this.getIsConsultInProgress(); + const isOnHold = state?.matches(TaskState.HELD) ?? this.isInteractionOnHold(); + const isConferenceInProgress = + state?.matches(TaskState.CONFERENCING) ?? this.getIsConferenceInProgress(); + + return { + interaction: this.data?.interaction ?? null, + callDetails: this.getCallAssociatedDetails(), + consultStatus, + // Derived from state machine state, fallback to computed + isConsultInProgress, + isOnHold, + isHeld: isOnHold, + consultCallHeld: agentId ? this.findHoldStatus(MEDIA_TYPE_CONSULT, agentId) : false, + isConferenceInProgress, + // Complex properties still computed from this.data + conferenceParticipantsCount: this.getConferenceParticipantsCount(), + conferenceParticipants: agentId ? this.getConferenceParticipants(agentId) : [], + isCustomerInCall: this.getIsCustomerInCall(), + mpcState: agentId ? this.getConsultMPCState(agentId) : this.data?.interaction?.state ?? null, + consultingAgent: agentId ? this.getConsultingAgentParticipant(agentId) : null, + autoWrapupSeconds: this.getAutoWrapupSeconds(), + canCancelAutoWrapup: this.canCancelAutoWrapup(), + isConsultInitiated, + isConsultAccepted, + isBeingConsulted, + isConsultCompleted, + isConsultInitiatedOrAccepted, + isConsultInitiatedOrAcceptedOnly, + isConsultInitiatedOrAcceptedOrBeingConsulted, + isConsultReceived, + isConsultInitiatedAndAccepted, + isIncomingTask: agentId ? this.isIncomingTask(agentId) : false, + }; + } + /** * Initialize the state machine with custom action callbacks */ @@ -207,11 +558,6 @@ export default abstract class Task extends EventEmitter implements ITask { } ); this.state = state; - // Compute derived properties after state transition - const agentId = this.data.agentId; - if (agentId) { - this.computeDerivedProperties(agentId); - } // Update UI controls based on current state this.updateUIControlsFromState(); @@ -236,15 +582,40 @@ export default abstract class Task extends EventEmitter implements ITask { } /** - * Update UI controls based on the current state machine state - * Child classes should override this to provide specific UI control logic + * Compute UI controls based on current state machine state. + * This method should be overridden by child classes (Voice, Digital) + * to provide channel-specific UI control logic. + * + * @returns UI control states for all task actions + */ + protected computeUIControls(): TaskUIControls { + // Default implementation - all controls hidden + // Child classes should override this method + return { + accept: {visible: false, enabled: false}, + decline: {visible: false, enabled: false}, + hold: {visible: false, enabled: false}, + mute: {visible: false, enabled: false}, + end: {visible: false, enabled: false}, + transfer: {visible: false, enabled: false}, + consult: {visible: false, enabled: false}, + consultTransfer: {visible: false, enabled: false}, + endConsult: {visible: false, enabled: false}, + recording: {visible: false, enabled: false}, + conference: {visible: false, enabled: false}, + wrapup: {visible: false, enabled: false}, + exitConference: {visible: false, enabled: false}, + transferConference: {visible: false, enabled: false}, + mergeToConference: {visible: false, enabled: false}, + }; + } + + /** + * @deprecated Legacy method - no longer needed with computed UI controls + * Child classes no longer need to override this. */ protected updateUIControlsFromState(): void { - // Default implementation - child classes should override - LoggerProxy.log('Updating UI controls from state', { - module: CC_FILE, - method: 'updateUIControlsFromState', - }); + // No-op - UI controls are now computed via derived.uiControls } /** @@ -332,27 +703,13 @@ export default abstract class Task extends EventEmitter implements ITask { return oldData; } - private initialiseUIControls() { - this.taskUiControls = { - accept: new TaskButtonControl(false, false), - decline: new TaskButtonControl(false, false), - hold: new TaskButtonControl(false, false), - mute: new TaskButtonControl(false, false), - end: new TaskButtonControl(false, false), - transfer: new TaskButtonControl(false, false), - consult: new TaskButtonControl(false, false), - consultTransfer: new TaskButtonControl(false, false), - endConsult: new TaskButtonControl(false, false), - recording: new TaskButtonControl(false, false), - conference: new TaskButtonControl(false, false), - wrapup: new TaskButtonControl(false, false), - }; - } - /** - * This method is used to set the UI controls data. Will be implemented in child classes. + * @deprecated Legacy method - UI controls are now computed via derived.uiControls + * This method is kept for backward compatibility but does nothing. */ - protected setUIControls() {} + protected setUIControls() { + // No-op - UI controls are now computed automatically + } /** * @@ -368,166 +725,444 @@ export default abstract class Task extends EventEmitter implements ITask { } /** - * Apply visibility & enabled flags in one go. - * Usage: updateTaskUiControls({ hold: [true,true], end: [false,true] }) + * @deprecated Legacy method - UI controls are now computed via derived.uiControls + * This method is kept for backward compatibility but does nothing. + * Child classes no longer need to call this method. */ - protected updateTaskUiControls( - config: Partial> - ): void { - Object.entries(config).forEach(([k, [vis, en]]) => { - const ctl = this.taskUiControls[k as keyof typeof this.taskUiControls]; - if (ctl) { - ctl.setVisiblity(vis); - ctl.setEnabled(en); - } - }); + protected updateTaskUiControls(): void { + // No-op - UI controls are now computed automatically from state machine } - /** - * Compute derived properties from state machine context - * Called whenever task data is updated or state transitions occur - */ - protected computeDerivedProperties(agentId: string): void { - const state = this.stateMachineService?.state; - if (!state) return; + private getCallAssociatedDetails(): Record | null { + const interaction = this.data?.interaction as Record | undefined; - const {context} = state; + return interaction?.callAssociatedDetails ?? null; + } - try { - // Compute consultStatus - this.data.consultStatus = this.getConsultStatus(agentId); + private getConsultingAgentParticipant(agentId?: string | null): Participant | null { + if (!agentId || !this.data?.interaction?.participants) { + return null; + } - // Compute isConsultInProgress - this.data.isConsultInProgress = state.matches(TaskState.CONSULTING); + const participants = Object.values(this.data.interaction.participants) as Array; - // Compute isOnHold - this.data.isOnHold = state.matches(TaskState.HELD); + const consultingAgent = participants.find((participant) => { + if (!participant) { + return false; + } + const participantId = participant.id ?? participant.participantId; + const participantType = + typeof participant.pType === 'string' ? participant.pType.toUpperCase() : ''; - // Compute isConferenceInProgress (already exists but ensure consistency) - this.data.isConferenceInProgress = - state.matches(TaskState.CONFERENCING) && context.participants.length >= 2; + if (participantId === agentId) { + return false; + } - // Compute isCustomerInCall - this.data.isCustomerInCall = this.checkCustomerInCall(); + if (participant.hasLeft) { + return false; + } - // Compute conferenceParticipantsCount - this.data.conferenceParticipantsCount = context.participants.length; + return participantType === 'AGENT'; + }); - // Compute isSecondaryAgent - this.data.isSecondaryAgent = this.checkIsSecondaryAgent(); + if (!consultingAgent) { + return null; + } - // Compute isSecondaryEpDnAgent - this.data.isSecondaryEpDnAgent = - this.data.interaction.mediaType === 'telephony' && this.data.isSecondaryAgent; + return { + id: consultingAgent.id ?? consultingAgent.participantId ?? '', + name: consultingAgent.name ?? consultingAgent.id ?? consultingAgent.participantId ?? '', + pType: consultingAgent.pType, + }; + } - // Compute mpcState - this.data.mpcState = this.getMPCState(agentId); + private getAgentJoinTimestamp(agentId?: string | null): number | null { + if (!agentId) { + return null; + } + + const participant = this.data?.interaction?.participants?.[agentId]; + const joinTimestamp = participant?.joinTimestamp; + + return typeof joinTimestamp === 'number' ? joinTimestamp : null; + } + + private getAutoWrapupSeconds(): number | null { + if (!this.autoWrapup || typeof this.autoWrapup.getTimeLeftSeconds !== 'function') { + return null; + } + + try { + const timeLeft = this.autoWrapup.getTimeLeftSeconds(); + + return typeof timeLeft === 'number' && Number.isFinite(timeLeft) ? timeLeft : null; } catch (error) { - LoggerProxy.error('Error computing derived properties', { + LoggerProxy.warn('AutoWrapup getTimeLeftSeconds failed', { module: CC_FILE, - method: 'computeDerivedProperties', - error: error.message, + method: 'getAutoWrapupSeconds', + error: (error as Error).message, }); + + return null; } } + private canCancelAutoWrapup(): boolean { + return Boolean( + (this.autoWrapup as {allowCancelAutoWrapup?: boolean} | undefined)?.allowCancelAutoWrapup + ); + } + /** - * Get consultation status from state machine + * Determine if task is incoming for given agent */ - private getConsultStatus(agentId: string): string { - const state = this.stateMachineService?.state; - if (!state) return 'NONE'; - const participants = this.data.interaction?.participants || {}; - const participant: any = Object.values(participants).find( - (p: any) => p.pType === 'Agent' && p.id === agentId + public isIncomingTask(agentId: string): boolean { + const taskData = this.data; + const taskState = taskData?.interaction?.state; + const participants = taskData?.interaction?.participants; + const hasJoined = agentId && participants?.[agentId]?.hasJoined; + + return ( + !taskData?.wrapUpRequired && + !hasJoined && + (taskState === INTERACTION_STATE_NEW || + taskState === INTERACTION_STATE_CONSULT || + taskState === INTERACTION_STATE_CONNECTED || + taskState === INTERACTION_STATE_CONFERENCE) ); + } - if (state.matches(TaskState.CONSULT_INITIATED)) { - return participant?.isConsulted ? 'BEING_CONSULTED' : 'CONSULT_INITIATED'; + /** + * Get consultation status derived from interaction state + */ + private getConsultStatus(agentId: string): string { + if (!agentId) { + return 'NO_CONSULTATION_IN_PROGRESS'; } - if (state.matches(TaskState.CONSULTING)) { - return participant?.isConsulted ? 'BEING_CONSULTED_ACCEPTED' : 'CONSULT_ACCEPTED'; + if (!this.data?.interaction) { + return 'NO_CONSULTATION_IN_PROGRESS'; } - if (state.matches(TaskState.CONNECTED)) { + + const state = this.getTaskStatus(agentId); + const participants = this.data.interaction.participants || {}; + const participant = participants[agentId]; + const beingConsulted = Boolean(participant?.isConsulted) || this.isSecondaryEpDnAgent(); + + if (state === TASK_STATE_CONSULT) { + return beingConsulted ? 'BEING_CONSULTED' : 'CONSULT_INITIATED'; + } + if (state === TASK_STATE_CONSULTING) { + return beingConsulted ? 'BEING_CONSULTED_ACCEPTED' : 'CONSULT_ACCEPTED'; + } + if (state === INTERACTION_STATE_CONNECTED) { return 'CONNECTED'; } - if (state.matches(TaskState.CONFERENCING)) { + if (state === INTERACTION_STATE_CONFERENCE) { return 'CONFERENCE'; } - if (state.matches(TaskState.CONSULT_COMPLETED)) { + if (state === TASK_STATE_CONSULT_COMPLETED) { return 'CONSULT_COMPLETED'; } return 'NO_CONSULTATION_IN_PROGRESS'; } - /** - * Check if customer is in call - */ - private checkCustomerInCall(): boolean { - if (!this.data?.interaction?.media || !this.data?.interactionId) { - return false; + private getTaskStatus(agentId: string): string { + if (!agentId) { + return 'NO_CONSULTATION_IN_PROGRESS'; + } + const interaction = this.data.interaction; + if (!interaction) { + return 'NO_CONSULTATION_IN_PROGRESS'; } - const mediaMainCall = this.data.interaction.media[this.data.interactionId]; - const participantsInMainCall = new Set(mediaMainCall?.participants); - const participants = this.data.interaction?.participants; + if (this.isSecondaryEpDnAgent()) { + if (interaction.state === INTERACTION_STATE_CONFERENCE) { + return INTERACTION_STATE_CONFERENCE; + } - if (participantsInMainCall.size > 0 && participants) { - return Array.from(participantsInMainCall).some((participantId: string) => { - const participant = participants[participantId]; + return TASK_STATE_CONSULTING; + } - return participant && participant.pType === 'CUSTOMER' && !participant.hasLeft; - }); + if ( + (interaction.state === INTERACTION_STATE_WRAPUP || + interaction.state === INTERACTION_STATE_POST_CALL) && + interaction.participants?.[agentId]?.consultState === CONSULT_STATE_COMPLETED + ) { + return TASK_STATE_CONSULT_COMPLETED; } - return false; + return this.getConsultMPCState(agentId); } - /** - * Check if this is a secondary agent (consulted party) - */ - private checkIsSecondaryAgent(): boolean { + private getConsultMPCState(agentId: string): string { + const interaction = this.data.interaction; + const consultMediaResourceId = this.findMediaResourceId(MEDIA_TYPE_CONSULT); + + if ( + consultMediaResourceId && + interaction.participants?.[agentId]?.consultState && + interaction.state !== INTERACTION_STATE_WRAPUP && + interaction.state !== INTERACTION_STATE_POST_CALL + ) { + const consultState = interaction.participants[agentId]?.consultState; + + switch (consultState) { + case CONSULT_STATE_INITIATED: + return TASK_STATE_CONSULT; + case CONSULT_STATE_COMPLETED: + return interaction.state === INTERACTION_STATE_CONNECTED + ? INTERACTION_STATE_CONNECTED + : TASK_STATE_CONSULT_COMPLETED; + case CONSULT_STATE_CONFERENCING: + return INTERACTION_STATE_CONFERENCE; + default: + return TASK_STATE_CONSULTING; + } + } + + return interaction?.state || 'NO_CONSULTATION_IN_PROGRESS'; + } + + private isSecondaryAgent(): boolean { const interaction = this.data.interaction; return ( !!interaction.callProcessingDetails && - interaction.callProcessingDetails.relationshipType === 'CONSULT' && + interaction.callProcessingDetails.relationshipType === RELATIONSHIP_TYPE_CONSULT && !!interaction.callProcessingDetails.parentInteractionId && interaction.callProcessingDetails.parentInteractionId !== interaction.interactionId ); } - /** - * Get MPC state based on participant consultState - */ - private getMPCState(agentId: string): string { + private isSecondaryEpDnAgent(): boolean { + return this.data.interaction.mediaType === MEDIA_TYPE_TELEPHONY && this.isSecondaryAgent(); + } + + public getIsConferenceInProgress(): boolean { + const interaction = this.data?.interaction; + const interactionId = this.data?.interactionId; + + if (!interaction?.media || !interactionId) { + return false; + } + + const mediaMainCall = interaction.media[interactionId]; + const participantsInMainCall = new Set(mediaMainCall?.participants); + const participants = interaction.participants ?? {}; + + let agentCount = 0; + participantsInMainCall.forEach((participantId: string) => { + const participant = participants[participantId]; + if ( + participant && + !EXCLUDED_PARTICIPANT_TYPES.includes(participant.pType) && + !participant.hasLeft + ) { + agentCount += 1; + } + }); + + return agentCount >= 2; + } + + public getConferenceParticipants(agentId?: string): Participant[] { + const participantsList: Participant[] = []; + const interaction = this.data?.interaction; + const interactionId = this.data?.interactionId; + + if (!interaction?.media || !interactionId) { + return participantsList; + } + + const mediaMainCall = interaction.media?.[interactionId]; + const participantsInMainCall = new Set(mediaMainCall?.participants ?? []); + const participants = interaction.participants ?? {}; + + participantsInMainCall.forEach((participantId: string) => { + const participant = participants[participantId]; + if ( + participant && + !EXCLUDED_PARTICIPANT_TYPES.includes(participant.pType) && + !participant.hasLeft && + participant.id !== agentId + ) { + participantsList.push({ + id: participant.id, + pType: participant.pType, + name: participant.name || participant.id, + }); + } + }); + + return participantsList; + } + + public getConferenceParticipantsCount(): number { + const interaction = this.data?.interaction; + const interactionId = this.data?.interactionId; + + if (!interaction?.media || !interactionId) { + return 0; + } + + const mediaMainCall = interaction.media?.[interactionId]; + const participantsInMainCall = new Set(mediaMainCall?.participants ?? []); + const participants = interaction.participants ?? {}; + + let count = 0; + participantsInMainCall.forEach((participantId: string) => { + const participant = participants[participantId]; + if ( + participant && + !EXCLUDED_PARTICIPANT_TYPES.includes(participant.pType) && + !participant.hasLeft + ) { + count += 1; + } + }); + + return count; + } + + public getIsCustomerInCall(): boolean { + const interaction = this.data?.interaction; + const interactionId = this.data?.interactionId; + + if (!interaction?.media || !interactionId) { + return false; + } + + const mediaMainCall = interaction.media[interactionId]; + const participantsInMainCall = new Set(mediaMainCall?.participants); + const participants = interaction.participants ?? {}; + + for (const participantId of participantsInMainCall) { + const participant = participants[participantId]; + if (participant && participant.pType === PARTICIPANT_TYPE_CUSTOMER && !participant.hasLeft) { + return true; + } + } + + return false; + } + + public getIsConsultInProgress(): boolean { + const media = this.data?.interaction?.media; + if (!media) { + return false; + } + + return Object.values(media).some((entry: any) => entry?.mType === MEDIA_TYPE_CONSULT); + } + + public isInteractionOnHold(): boolean { + const media = this.data?.interaction?.media; + if (!media) { + return false; + } + + return Object.values(media).some((entry: any) => Boolean(entry?.isHold)); + } + + private setMediaTypeForEpDn(mType: string): string { + if (this.isSecondaryEpDnAgent()) { + return 'mainCall'; + } + + return mType; + } + + public findMediaResourceId(mType: string): string { + const mediaEntries = this.data?.interaction?.media; + if (!mediaEntries) { + return ''; + } + + const normalizedType = this.setMediaTypeForEpDn(mType); + + for (const key of Object.keys(mediaEntries)) { + const media = (mediaEntries as Record)[key]; + if (media?.mType === normalizedType) { + return media.mediaResourceId ?? key; + } + } + + return ''; + } + + private isConsultOnHoldMPC(agentId: string): boolean { + const currentState = this.getConsultMPCState(agentId); + const isInConsultState = + currentState === TASK_STATE_CONSULT || currentState === TASK_STATE_CONSULTING; + const consultMediaResourceId = this.findMediaResourceId(MEDIA_TYPE_CONSULT); + let mediaEntry: any; + if (consultMediaResourceId) { + mediaEntry = (this.data.interaction.media as Record)[consultMediaResourceId]; + if (!mediaEntry) { + mediaEntry = Object.values(this.data.interaction.media as Record).find( + (entry) => entry?.mediaResourceId === consultMediaResourceId + ); + } + } + + const isConsultHold = consultMediaResourceId && mediaEntry?.isHold; + + return isInConsultState && !isConsultHold; + } + + public findHoldStatus(mType: string, agentId: string): boolean { const interaction = this.data.interaction; - const currentState = this.getCurrentState(); + if (!agentId || !interaction?.media) { + return false; + } + + const normalizedType = this.setMediaTypeForEpDn(mType); + const mediaId = this.findMediaResourceId(normalizedType); + let mediaEntry = (interaction.media as Record)[mediaId]; + if (!mediaEntry) { + mediaEntry = Object.values(interaction.media as Record).find( + (entry) => entry?.mediaResourceId === mediaId + ); + } + + if (!mediaEntry) { + return false; + } if ( - !this.data.consultMediaResourceId || - !interaction.participants[agentId]?.consultState || - currentState === TaskState.WRAPPING_UP || - currentState === TaskState.POST_CALL + normalizedType === 'mainCall' && + mediaEntry.participants?.includes(agentId) && + (this.isConsultOnHoldMPC(agentId) || + this.getConsultMPCState(agentId) === TASK_STATE_CONSULT_COMPLETED) ) { - return interaction?.state || (currentState as string); + return true; } - const consultState = interaction.participants[agentId]?.consultState; + if (normalizedType === MEDIA_TYPE_CONSULT && mediaEntry.participants?.includes(agentId)) { + return Boolean(mediaEntry.isHold); + } - switch (consultState) { - case 'INITIATED': - return TaskState.CONSULT_INITIATED; - case 'COMPLETED': - return currentState === TaskState.CONNECTED - ? TaskState.CONNECTED - : TaskState.CONSULT_COMPLETED; - case 'CONFERENCING': - return TaskState.CONFERENCING; - default: - return TaskState.CONSULTING; + return Boolean(mediaEntry.isHold); + } + + public findHoldTimestamp(mType = 'mainCall'): number | null { + const interaction = this.data?.interaction; + const media = interaction?.media; + if (!media) { + return null; } + + const normalizedType = this.setMediaTypeForEpDn(mType); + + for (const key of Object.keys(media)) { + const mediaEntry = (media as Record)[key]; + if (mediaEntry?.mType === normalizedType) { + return mediaEntry.holdTimestamp ?? null; + } + } + + return null; } /** @@ -543,12 +1178,6 @@ export default abstract class Task extends EventEmitter implements ITask { public updateTaskData(updatedData: TaskData, shouldOverwrite = false): ITask { this.data = shouldOverwrite ? updatedData : this.reconcileData(this.data, updatedData); - // Compute derived properties from state machine - const agentId = this.data.agentId; - if (agentId) { - this.computeDerivedProperties(agentId); - } - this.setUIControls(); return this; diff --git a/packages/@webex/contact-center/src/services/task/state-machine/actions.ts b/packages/@webex/contact-center/src/services/task/state-machine/actions.ts index 72ab2202fc6..75555bb5a07 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/actions.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/actions.ts @@ -24,13 +24,11 @@ export function createInitialContext(): TaskContext { currentState: TaskState.IDLE, previousState: null, mediaResourceId: null, - isConsulted: false, consultInitiator: false, consultDestination: null, consultDestinationType: null, consultDestinationAgentJoined: false, consultMediaResourceId: null, - isConferencing: false, conferenceInitiatorId: null, conferenceParticipants: [], maxConferenceParticipants: 10, @@ -38,20 +36,12 @@ export function createInitialContext(): TaskContext { isPrimary: false, recordingActive: false, recordingPaused: false, - isHold: false, wrapUpRequired: false, autoWrapupTimer: null, ronaTimer: null, offeredAt: null, connectedAt: null, endedAt: null, - // Action availability flags - canHold: false, - canResume: false, - canConsult: false, - canEndConsult: false, - canTransfer: false, - canWrapup: false, }; } @@ -68,7 +58,6 @@ export const actions = { return { taskData: event.taskData, offeredAt: Date.now(), - isConsulted: event.type === TaskEvent.OFFER_CONSULT, }; } @@ -109,7 +98,6 @@ export const actions = { return { consultDestination: event.destination, consultDestinationType: event.destinationType, - isConsulted: true, }; } @@ -137,7 +125,6 @@ export const actions = { const participantIds = event.participants?.map((p) => p.id) || []; return { - isConferencing: true, conferenceParticipants: event.participants || [], participants: participantIds, }; @@ -158,7 +145,6 @@ export const actions = { } return { - isConferencing: true, conferenceInitiatorId: agentId, conferenceParticipants: [ { @@ -237,7 +223,6 @@ export const actions = { * Clear conferencing state */ clearConferencing: assign({ - isConferencing: false, conferenceInitiatorId: null, conferenceParticipants: [], participants: [], @@ -249,13 +234,11 @@ export const actions = { setHoldState: assign((context, event) => { if (isEventOfType(event, TaskEvent.HOLD)) { return { - isHold: true, mediaResourceId: event.mediaResourceId, }; } if (isEventOfType(event, TaskEvent.UNHOLD)) { return { - isHold: false, mediaResourceId: event.mediaResourceId, }; } diff --git a/packages/@webex/contact-center/src/services/task/state-machine/guards.ts b/packages/@webex/contact-center/src/services/task/state-machine/guards.ts index 8c1e961719c..a51f1263c0b 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/guards.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/guards.ts @@ -31,16 +31,9 @@ export const guards = { */ canHold: (context: TaskContext, event: any, meta: {state: {value: StateValue}}): boolean => { const state = meta.state.value as TaskState; - if (state !== TaskState.CONNECTED) { - return false; - } - - // Check if already on hold - if (context.isHold) { - return false; - } - return true; + // Can only hold if in CONNECTED state (not already HELD) + return state === TaskState.CONNECTED; }, /** @@ -48,16 +41,9 @@ export const guards = { */ canResume: (context: TaskContext, event: any, meta: {state: {value: StateValue}}): boolean => { const state = meta.state.value as TaskState; - if (state !== TaskState.HELD) { - return false; - } - - // Must be on hold to resume - if (!context.isHold) { - return false; - } - return true; + // Can only resume if in HELD state + return state === TaskState.HELD; }, /** @@ -65,17 +51,9 @@ export const guards = { */ canConsult: (context: TaskContext, event: any, meta: {state: {value: StateValue}}): boolean => { const state = meta.state.value as TaskState; - // Must be in CONNECTED or HELD state - if (state !== TaskState.CONNECTED && state !== TaskState.HELD) { - return false; - } - // Cannot consult if already in conference - if (context.isConferencing) { - return false; - } - - return true; + // Can consult if in CONNECTED or HELD state (CONFERENCING is a separate state) + return state === TaskState.CONNECTED || state === TaskState.HELD; }, /** @@ -135,9 +113,12 @@ export const guards = { /** * Check if current task is from a consult offer + * Now derived from state instead of context flag */ - isConsulted: (context: TaskContext): boolean => { - return context.isConsulted; + isConsulted: (context: TaskContext, event: any, meta: {state: {value: StateValue}}): boolean => { + const state = meta.state.value as TaskState; + + return state === TaskState.CONSULTING; }, /** @@ -170,7 +151,6 @@ export const guards = { return ( state === TaskState.CONSULTING && context.consultDestinationAgentJoined && - !context.isConferencing && context.conferenceParticipants.length === 0 ); }, @@ -186,9 +166,8 @@ export const guards = { const state = meta.state.value as TaskState; return ( - context.isConferencing && - context.conferenceParticipants.length < context.maxConferenceParticipants && - state === TaskState.CONFERENCING + state === TaskState.CONFERENCING && + context.conferenceParticipants.length < context.maxConferenceParticipants ); }, diff --git a/packages/@webex/contact-center/src/services/task/state-machine/types.ts b/packages/@webex/contact-center/src/services/task/state-machine/types.ts index 9f3ccbb01bc..d28558346b2 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/types.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/types.ts @@ -113,6 +113,20 @@ export interface ConferenceParticipant { /** * Context data maintained by the state machine + * + * IMPORTANT: This context should only store data that CANNOT be derived from the state machine's current state. + * + * STATE-DERIVED PROPERTIES (NOT stored in context, derived from state machine state): + * - isHold: Derived from state === TaskState.HELD + * - isConsulted: Derived from state === TaskState.CONSULTING or state === TaskState.OFFERED_CONSULT + * - isConferencing: Derived from state === TaskState.CONFERENCING + * - isConnected: Derived from state === TaskState.CONNECTED + * - isWrappingUp: Derived from state === TaskState.WRAPPING_UP + * - isOffered: Derived from state === TaskState.OFFERED or state === TaskState.OFFERED_CONSULT + * + * These boolean flags were removed because they duplicate information already available + * in the state machine's current state, violating the single source of truth principle. + * Use state.matches(TaskState.XXX) instead to check these conditions. */ export interface TaskContext { // Task data @@ -126,7 +140,6 @@ export interface TaskContext { mediaResourceId: string | null; // Consult tracking - isConsulted: boolean; consultInitiator: boolean; consultDestination: string | null; consultDestinationType: 'agent' | 'queue' | 'entryPoint' | null; @@ -134,7 +147,6 @@ export interface TaskContext { consultMediaResourceId: string | null; // Conference tracking - isConferencing: boolean; conferenceInitiatorId: string | null; conferenceParticipants: ConferenceParticipant[]; maxConferenceParticipants: number; @@ -146,9 +158,6 @@ export interface TaskContext { recordingActive: boolean; recordingPaused: boolean; - // Hold tracking - isHold: boolean; - // Wrapup tracking wrapUpRequired: boolean; autoWrapupTimer: number | null; @@ -160,14 +169,6 @@ export interface TaskContext { offeredAt: number | null; connectedAt: number | null; endedAt: number | null; - - // Action availability flags - canHold: boolean; - canResume: boolean; - canConsult: boolean; - canEndConsult: boolean; - canTransfer: boolean; - canWrapup: boolean; } /** diff --git a/packages/@webex/contact-center/src/services/task/types.ts b/packages/@webex/contact-center/src/services/task/types.ts index 1bad464d8fb..7ac80a775de 100644 --- a/packages/@webex/contact-center/src/services/task/types.ts +++ b/packages/@webex/contact-center/src/services/task/types.ts @@ -778,6 +778,11 @@ export type TaskData = { */ isConsultInProgress?: boolean; + /** + * Indicates if the task is incoming for the active agent + */ + isIncomingTask?: boolean; + /** * Indicates if the task is on hold (state machine: HELD) */ diff --git a/packages/@webex/contact-center/src/services/task/voice/Voice.ts b/packages/@webex/contact-center/src/services/task/voice/Voice.ts index 2db1591ef42..30bbbeee6f2 100644 --- a/packages/@webex/contact-center/src/services/task/voice/Voice.ts +++ b/packages/@webex/contact-center/src/services/task/voice/Voice.ts @@ -1,5 +1,9 @@ -import {CC_FILE, METHODS} from '../../../constants'; -import {getErrorDetails} from '../../core/Utils'; +import {CC_FILE, METHODS, TASK_FILE} from '../../../constants'; +import { + getErrorDetails, + buildConsultConferenceParamData, + generateTaskErrorObject, +} from '../../core/Utils'; import routingContact from '../contact'; import { ConsultPayload, @@ -89,15 +93,11 @@ export default class Voice extends Task implements IVoice { switch (operation) { case 'hold': - return state.matches(TaskState.CONNECTED) && !state.context.isHold; + return state.matches(TaskState.CONNECTED); case 'resume': - return state.matches(TaskState.HELD) && state.context.isHold; + return state.matches(TaskState.HELD); case 'consult': - return ( - (state.matches(TaskState.CONNECTED) || state.matches(TaskState.HELD)) && - !state.context.isConsulted && - !state.context.isConferencing - ); + return state.matches(TaskState.CONNECTED) || state.matches(TaskState.HELD); case 'conference': return state.matches(TaskState.CONSULTING) && state.context.consultDestinationAgentJoined; case 'transfer': @@ -123,70 +123,123 @@ export default class Voice extends Task implements IVoice { } /** - * Legacy helper for consulting controls + * Compute UI controls based on state machine state. + * This replaces the old updateUIControlsFromState() method. + * Returns plain objects instead of mutating taskUiControls. */ - private applyConsultingControls(): void { - this.updateTaskUiControls({ - hold: [false, false], - transfer: [false, false], - consult: [false, false], - recording: [true, false], - }); + protected override computeUIControls(): import('../Task').TaskUIControls { + const state = this.stateMachineService?.state; - if (!this.data.isConsulted) { - this.updateTaskUiControls({ - consultTransfer: [true, true], - endConsult: [true, true], - end: [this.isEndCallEnabled, false], - }); - } else { - this.updateTaskUiControls({endConsult: [this.isEndConsultEnabled, this.isEndConsultEnabled]}); + if (!state) { + // Fallback if state machine not initialized + return super.computeUIControls(); } - } - /** - * State-based UI control logic, driven by state machine context. - * This method derives UI control states directly from the `can*` flags - * in the state machine's context, ensuring a single source of truth. - */ - protected updateUIControlsFromState(): void { - const state = this.stateMachineService?.state; - if (!state) { - // Fallback to legacy logic if state machine is not yet initialized - this.setUIControls(); + // Determine UI control states based on current state and context + const isOffered = state.matches(TaskState.OFFERED) || state.matches(TaskState.OFFERED_CONSULT); + const isConnected = state.matches(TaskState.CONNECTED); + const isHeld = state.matches(TaskState.HELD); + const isConsulting = state.matches(TaskState.CONSULTING); + const isConferencing = state.matches(TaskState.CONFERENCING); + const isWrappingUp = state.matches(TaskState.WRAPPING_UP); - return; - } + const context = state.context; - const {context} = state; - const {canHold, canResume, canConsult, canEndConsult, canTransfer, canWrapup, isHold} = context; + // Return computed UI controls based on state machine state + return { + // Accept button: visible when offered, always enabled + accept: { + visible: isOffered, + enabled: true, + }, - const isOffered = state.matches(TaskState.OFFERED) || state.matches(TaskState.OFFERED_CONSULT); + // Decline button: visible when offered, always enabled + decline: { + visible: isOffered, + enabled: true, + }, - this.updateTaskUiControls({ - accept: this.uiControl(isOffered, true), - decline: this.uiControl(isOffered, true), - hold: this.uiControl(canHold || canResume, canHold || canResume), - transfer: this.uiControl(canTransfer, canTransfer), - consult: this.uiControl(canConsult, canConsult), - endConsult: this.uiControl(canEndConsult, canEndConsult), - wrapup: this.uiControl(canWrapup, canWrapup), - end: this.uiControl(this.isEndCallEnabled, !isHold), - // Recording and conference controls can be added here as well - }); - } + // Hold button: visible when connected or held + // Enabled based on current state (hold when connected, resume when held) + hold: { + visible: isConnected || isHeld, + enabled: isConnected || isHeld, + }, - /** - * @deprecated Legacy event-based UI control logic. Kept for backward compatibility. - * This will be removed once the state machine is fully adopted. - */ - protected setUIControls(): void { - // This method is now a fallback and will be removed. - // The logic has been migrated to `updateUIControlsFromState`. - LoggerProxy.warn('Legacy setUIControls() called. This method is deprecated.', { - module: CC_FILE, - method: 'setUIControls', - }); + // Mute button: visible when active call, disabled during wrapup + mute: { + visible: isConnected || isHeld, + enabled: !isWrappingUp, + }, + + // End button: conditional based on config, disabled when held or wrapping up + end: { + visible: this.isEndCallEnabled, + enabled: !isHeld && !isWrappingUp, + }, + + // Transfer button: visible in connected/held/consulting states + transfer: { + visible: isConnected || isHeld || isConsulting, + enabled: true, + }, + + // Consult button: visible when connected or held + // Enabled when in connected or held states (not consulting/conferencing) + consult: { + visible: isConnected || isHeld, + enabled: isConnected || isHeld, + }, + + // Consult transfer: visible during consulting + consultTransfer: { + visible: isConsulting, + enabled: true, + }, + + // End consult button: visible during consulting state + endConsult: { + visible: isConsulting, + enabled: this.isEndConsultEnabled, + }, + + // Recording controls: based on recording state + recording: { + visible: isConnected || isHeld, + enabled: !context.recordingPaused, + }, + + // Conference button: visible during consulting + // Enabled only if consulted agent has joined + conference: { + visible: isConsulting, + enabled: context.consultDestinationAgentJoined, + }, + + // Wrapup button: visible during wrapup state + wrapup: { + visible: isWrappingUp, + enabled: true, + }, + + // Exit conference button: visible during conference + exitConference: { + visible: isConferencing, + enabled: true, + }, + + // Transfer conference: visible during conference + transferConference: { + visible: isConferencing, + enabled: true, + }, + + // Merge to conference: visible during consulting (alias for conference) + mergeToConference: { + visible: isConsulting, + enabled: context.consultDestinationAgentJoined, + }, + }; } /** @@ -257,7 +310,7 @@ export default class Voice extends Task implements IVoice { if (state) { const currentState = state.value as TaskState; if (shouldHold) { - if (!state.matches(TaskState.CONNECTED) || state.context.isHold) { + if (!state.matches(TaskState.CONNECTED)) { const error = new Error(`Cannot hold call in current state: ${currentState}`); LoggerProxy.error('Hold operation not allowed', { module: CC_FILE, @@ -266,7 +319,7 @@ export default class Voice extends Task implements IVoice { }); throw error; } - } else if (!state.matches(TaskState.HELD) || !state.context.isHold) { + } else if (!state.matches(TaskState.HELD)) { const error = new Error(`Cannot resume call in current state: ${currentState}`); LoggerProxy.error('Resume operation not allowed', { module: CC_FILE, @@ -511,18 +564,11 @@ export default class Voice extends Task implements IVoice { // Validate consult is allowed const state = this.stateMachineService?.state; const canConsult = - state && - (state.matches(TaskState.CONNECTED) || state.matches(TaskState.HELD)) && - !state.context.isConsulted && - !state.context.isConferencing; + state && (state.matches(TaskState.CONNECTED) || state.matches(TaskState.HELD)); if (!canConsult) { const currentState = state?.value as TaskState; - const error = new Error( - `Cannot initiate consult in ${currentState} state${ - state?.context.isConferencing ? ' (already in conference)' : '' - }` - ); + const error = new Error(`Cannot initiate consult in ${currentState} state`); LoggerProxy.error('Consult operation not allowed', { module: CC_FILE, method: 'consult', @@ -799,51 +845,239 @@ export default class Voice extends Task implements IVoice { /** * Initiates a consult conference (merge consult call with main call) + * Creates a three-way conference between the agent, customer, and consulted party * @returns Promise * @throws Error */ public async consultConference(): Promise { - // Validate conference can start - const state = this.stateMachineService?.state; - if (!state || !state.matches(TaskState.CONSULTING)) { - const error = new Error('Must be in consulting state to start conference'); - LoggerProxy.error('Conference operation not allowed', { - module: CC_FILE, + // Extract consultation conference data from task data (used in both try and catch) + const consultationData = { + agentId: this.data.agentId, + destAgentId: this.data.destAgentId, + destinationType: this.data.destinationType || 'agent', + }; + + try { + LoggerProxy.info(`Initiating consult conference to ${consultationData.destAgentId}`, { + module: TASK_FILE, method: METHODS.CONSULT_CONFERENCE, interactionId: this.data.interactionId, }); - throw error; - } - if (!state.context.consultDestinationAgentJoined) { - const error = new Error('Consult agent has not joined yet'); - LoggerProxy.error('Conference operation not allowed', { - module: CC_FILE, + const paramsDataForConferenceV2 = buildConsultConferenceParamData( + consultationData, + this.data.interactionId + ); + + const response = await this.contact.consultConference({ + interactionId: paramsDataForConferenceV2.interactionId, + data: paramsDataForConferenceV2.data, + }); + + // Track success metrics (following consultTransfer pattern) + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.TASK_CONFERENCE_START_SUCCESS, + { + taskId: this.data.interactionId, + destination: paramsDataForConferenceV2.data.to, + destinationType: paramsDataForConferenceV2.data.destinationType, + agentId: paramsDataForConferenceV2.data.agentId, + ...MetricsManager.getCommonTrackingFieldForAQMResponse(response), + }, + ['operational', 'behavioral', 'business'] + ); + + LoggerProxy.log(`Consult conference started successfully`, { + module: TASK_FILE, + method: METHODS.CONSULT_CONFERENCE, + interactionId: this.data.interactionId, + }); + + return response; + } catch (error) { + const err = generateTaskErrorObject(error, METHODS.CONSULT_CONFERENCE, TASK_FILE); + const taskErrorProps = { + trackingId: err.data?.trackingId, + errorMessage: err.data?.message, + errorType: err.data?.errorType, + errorData: err.data?.errorData, + reasonCode: err.data?.reasonCode, + }; + + // Track failure metrics (following consultTransfer pattern) + // Build conference data for error tracking using extracted data + const failedParamsData = buildConsultConferenceParamData( + consultationData, + this.data.interactionId + ); + + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.TASK_CONFERENCE_START_FAILED, + { + taskId: this.data.interactionId, + destination: failedParamsData.data.to, + destinationType: failedParamsData.data.destinationType, + agentId: failedParamsData.data.agentId, + error: error.toString(), + ...taskErrorProps, + ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed(error.details || {}), + }, + ['operational', 'behavioral', 'business'] + ); + + LoggerProxy.error(`Failed to start consult conference`, { + module: TASK_FILE, method: METHODS.CONSULT_CONFERENCE, interactionId: this.data.interactionId, }); - throw error; - } - super.unsupportedMethodError(METHODS.CONSULT_CONFERENCE); + throw err; + } } /** * Exits from an ongoing conference + * Exits the agent from the conference, leaving the customer and consulted party connected * @returns Promise * @throws Error */ public async exitConference(): Promise { - super.unsupportedMethodError(METHODS.EXIT_CONFERENCE); + try { + LoggerProxy.info(`Exiting consult conference`, { + module: TASK_FILE, + method: METHODS.EXIT_CONFERENCE, + interactionId: this.data.interactionId, + }); + + // Validate that interaction ID exists + if (!this.data.interactionId) { + throw new Error('Invalid interaction ID'); + } + + const response = await this.contact.exitConference({ + interactionId: this.data.interactionId, + }); + + // Track success metrics (following consultTransfer pattern) + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.TASK_CONFERENCE_END_SUCCESS, + { + taskId: this.data.interactionId, + ...MetricsManager.getCommonTrackingFieldForAQMResponse(response), + }, + ['operational', 'behavioral', 'business'] + ); + + LoggerProxy.log(`Consult conference exited successfully`, { + module: TASK_FILE, + method: METHODS.EXIT_CONFERENCE, + interactionId: this.data.interactionId, + }); + + return response; + } catch (error) { + const err = generateTaskErrorObject(error, METHODS.EXIT_CONFERENCE, TASK_FILE); + const taskErrorProps = { + trackingId: err.data?.trackingId, + errorMessage: err.data?.message, + errorType: err.data?.errorType, + errorData: err.data?.errorData, + reasonCode: err.data?.reasonCode, + }; + + // Track failure metrics (following consultTransfer pattern) + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.TASK_CONFERENCE_END_FAILED, + { + taskId: this.data.interactionId, + error: error.toString(), + ...taskErrorProps, + ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed(error.details || {}), + }, + ['operational', 'behavioral', 'business'] + ); + + LoggerProxy.error(`Failed to exit consult conference`, { + module: TASK_FILE, + method: METHODS.EXIT_CONFERENCE, + interactionId: this.data.interactionId, + }); + + throw err; + } } /** * Transfers the conference to another participant + * Moves the entire conference (including all participants) to a new agent, + * while the current agent exits and goes to wrapup * @returns Promise * @throws Error */ public async transferConference(): Promise { - super.unsupportedMethodError(METHODS.TRANSFER_CONFERENCE); + try { + LoggerProxy.info(`Transferring conference`, { + module: TASK_FILE, + method: METHODS.TRANSFER_CONFERENCE, + interactionId: this.data.interactionId, + }); + + // Validate that interaction ID exists + if (!this.data.interactionId) { + throw new Error('Invalid interaction ID'); + } + + const response = await this.contact.conferenceTransfer({ + interactionId: this.data.interactionId, + }); + + // Track success metrics (following consultTransfer pattern) + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.TASK_CONFERENCE_TRANSFER_SUCCESS, + { + taskId: this.data.interactionId, + ...MetricsManager.getCommonTrackingFieldForAQMResponse(response), + }, + ['operational', 'behavioral', 'business'] + ); + + LoggerProxy.log(`Conference transferred successfully`, { + module: TASK_FILE, + method: METHODS.TRANSFER_CONFERENCE, + interactionId: this.data.interactionId, + }); + + return response; + } catch (error) { + const err = generateTaskErrorObject(error, METHODS.TRANSFER_CONFERENCE, TASK_FILE); + const taskErrorProps = { + trackingId: err.data?.trackingId, + errorMessage: err.data?.message, + errorType: err.data?.errorType, + errorData: err.data?.errorData, + reasonCode: err.data?.reasonCode, + }; + + // Track failure metrics (following consultTransfer pattern) + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.TASK_CONFERENCE_TRANSFER_FAILED, + { + taskId: this.data.interactionId, + error: error.toString(), + ...taskErrorProps, + ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed(error.details || {}), + }, + ['operational', 'behavioral', 'business'] + ); + + LoggerProxy.error(`Failed to transfer conference`, { + module: TASK_FILE, + method: METHODS.TRANSFER_CONFERENCE, + interactionId: this.data.interactionId, + }); + + throw err; + } } /** From 5b416f64c19df3e6295baca299228e44184e47b0 Mon Sep 17 00:00:00 2001 From: arungane Date: Wed, 5 Nov 2025 12:25:10 -0500 Subject: [PATCH 06/14] fix(contact-center): revert the conference and derived state changes --- .../contact-center/src/services/task/Task.ts | 847 +----------------- .../src/services/task/digital/Digital.ts | 180 ++-- .../services/task/state-machine/actions.ts | 50 +- .../src/services/task/state-machine/types.ts | 13 - .../src/services/task/voice/Voice.ts | 407 +-------- .../src/services/task/voice/WebRTC.ts | 151 ++-- 6 files changed, 198 insertions(+), 1450 deletions(-) diff --git a/packages/@webex/contact-center/src/services/task/Task.ts b/packages/@webex/contact-center/src/services/task/Task.ts index 8f0135bcb0f..e8e55ae5039 100644 --- a/packages/@webex/contact-center/src/services/task/Task.ts +++ b/packages/@webex/contact-center/src/services/task/Task.ts @@ -21,7 +21,6 @@ import LoggerProxy from '../../logger-proxy'; import { createTaskStateMachineWithActions, createActionsWithCallbacks, - TaskEvent, TaskState, TaskContext, TaskEventPayload, @@ -29,38 +28,6 @@ import { } from './state-machine'; import AutoWrapup from './AutoWrapup'; -const PARTICIPANT_TYPE_AGENT = 'Agent'; -const PARTICIPANT_TYPE_CUSTOMER = 'Customer'; -const PARTICIPANT_TYPE_SUPERVISOR = 'Supervisor'; -const PARTICIPANT_TYPE_VVA = 'VVA'; - -const EXCLUDED_PARTICIPANT_TYPES = [ - PARTICIPANT_TYPE_AGENT, - PARTICIPANT_TYPE_CUSTOMER, - PARTICIPANT_TYPE_SUPERVISOR, - PARTICIPANT_TYPE_VVA, -]; - -const MEDIA_TYPE_CONSULT = 'consult'; -const MEDIA_TYPE_TELEPHONY = 'telephony'; - -const INTERACTION_STATE_NEW = 'new'; -const INTERACTION_STATE_CONSULT = 'consult'; -const INTERACTION_STATE_CONNECTED = 'connected'; -const INTERACTION_STATE_CONFERENCE = 'conference'; -const INTERACTION_STATE_WRAPUP = 'wrapup'; -const INTERACTION_STATE_POST_CALL = 'post_call'; - -const CONSULT_STATE_INITIATED = 'INITIATED'; -const CONSULT_STATE_COMPLETED = 'COMPLETED'; -const CONSULT_STATE_CONFERENCING = 'CONFERENCING'; - -const RELATIONSHIP_TYPE_CONSULT = 'CONSULT'; - -const TASK_STATE_CONSULT = 'consult'; -const TASK_STATE_CONSULTING = 'consulting'; -const TASK_STATE_CONSULT_COMPLETED = 'consult_completed'; - /** * Participant information for UI display */ @@ -70,90 +37,6 @@ export type Participant = { pType?: string; }; -/** - * Immutable task properties computed once at task creation. - * These properties don't change throughout the task lifecycle. - */ -export interface TaskImmutableProps { - /** Unique interaction identifier */ - readonly interactionId: string | null; - /** Media type (telephony, chat, email) */ - readonly mediaType: string | null; - /** Media channel identifier */ - readonly mediaChannel: string | null; - /** True if this is a telephony task */ - readonly isCall: boolean; - /** True if this is a chat task */ - readonly isChat: boolean; - /** True if this is an email task */ - readonly isEmail: boolean; - /** True if this is a digital channel (chat or email) */ - readonly isDigitalChannel: boolean; - /** True if agent is the secondary agent in consult/conference */ - readonly isSecondaryAgent: boolean; - /** True if agent is secondary EP/DN agent */ - readonly isSecondaryEpDnAgent: boolean; - /** Timestamp when agent joined the interaction */ - readonly agentJoinTimestamp: number | null; -} - -/** - * Dynamic task properties computed on-demand from current task state. - * These properties reflect the current state and can change as events occur. - */ -export interface TaskDynamicProps { - /** Full interaction data object */ - readonly interaction: TaskData['interaction'] | null; - /** Call-specific details */ - readonly callDetails: Record | null; - /** Current consultation status */ - readonly consultStatus: string | null; - /** True if consultation is in progress */ - readonly isConsultInProgress: boolean; - /** True if main call is on hold */ - readonly isOnHold: boolean; - /** Alias for isOnHold */ - readonly isHeld: boolean; - /** True if consult call is on hold */ - readonly consultCallHeld: boolean; - /** True if conference is active */ - readonly isConferenceInProgress: boolean; - /** Number of conference participants */ - readonly conferenceParticipantsCount: number; - /** List of conference participants */ - readonly conferenceParticipants: Participant[]; - /** True if customer is still in the call */ - readonly isCustomerInCall: boolean; - /** Current MPC (Multi-Party Conference) state */ - readonly mpcState: string | null; - /** Information about the consulting agent */ - readonly consultingAgent: Participant | null; - /** Remaining auto-wrapup time in seconds */ - readonly autoWrapupSeconds: number | null; - /** True if auto-wrapup can be cancelled */ - readonly canCancelAutoWrapup: boolean; - /** True if consult has been initiated */ - readonly isConsultInitiated: boolean; - /** True if consult has been accepted */ - readonly isConsultAccepted: boolean; - /** True if this agent is being consulted */ - readonly isBeingConsulted: boolean; - /** True if consult has completed */ - readonly isConsultCompleted: boolean; - /** True if consult is initiated or accepted */ - readonly isConsultInitiatedOrAccepted: boolean; - /** True if consult is initiated or accepted (not being consulted) */ - readonly isConsultInitiatedOrAcceptedOnly: boolean; - /** True if consult is initiated, accepted, or being consulted */ - readonly isConsultInitiatedOrAcceptedOrBeingConsulted: boolean; - /** True if this agent received a consult request */ - readonly isConsultReceived: boolean; - /** True if consult is both initiated and accepted */ - readonly isConsultInitiatedAndAccepted: boolean; - /** True if this is an incoming task for the agent */ - readonly isIncomingTask: boolean; -} - /** * UI control state for a single task action button. * Represents visibility and enabled state for UI components. @@ -187,21 +70,6 @@ export interface TaskUIControls { mergeToConference: UIControlState; } -/** - * Combined task properties for UI components. - * Provides convenient access to both immutable and dynamic task properties, - * plus computed UI controls based on state machine state. - */ -export interface TaskDerivedState extends TaskImmutableProps, TaskDynamicProps { - /** UI controls computed from state machine state */ - uiControls: TaskUIControls; -} - -/** - * @deprecated Use TaskDerivedState instead - */ -export type TaskAccessor = TaskDerivedState; - /** * @deprecated Use Participant instead */ @@ -214,14 +82,6 @@ export default abstract class Task extends EventEmitter implements ITask { public data: TaskData; public webCallMap: Record; public state: any; - private ronaTimerId?: NodeJS.Timeout; - private autoWrapupTimerId?: NodeJS.Timeout; - - /** - * Immutable task properties computed once at construction. - * These values don't change throughout the task lifecycle. - */ - private readonly immutableProps: TaskImmutableProps; constructor(contact: ReturnType, data: TaskData) { super(); @@ -229,10 +89,6 @@ export default abstract class Task extends EventEmitter implements ITask { this.data = data; this.metricsManager = MetricsManager.getInstance(); this.webCallMap = {}; - if (this.data?.agentId) { - this.data.isIncomingTask = this.isIncomingTask(this.data.agentId); - } - this.immutableProps = this.computeImmutableProps(); this.initializeStateMachine(); } @@ -304,15 +160,6 @@ export default abstract class Task extends EventEmitter implements ITask { return Promise.reject(new Error('toggleMute not supported for this channel type')); } - // Utility methods with default implementations - public cancelAutoWrapupTimer(): void { - // Default implementation - child classes can override - if (this.autoWrapupTimerId) { - clearTimeout(this.autoWrapupTimerId); - this.autoWrapupTimerId = undefined; - } - } - public unregisterWebCallListeners(): void { // Default implementation - child classes can override LoggerProxy.log('unregisterWebCallListeners called', { @@ -341,53 +188,13 @@ export default abstract class Task extends EventEmitter implements ITask { } /** - * Get computed task state for UI components. - * Combines immutable properties (computed once) with dynamic properties - * (computed fresh from current state) and UI controls (computed from state machine). - * - * @returns Combined immutable and dynamic task properties plus UI controls - * - * @example - * ```typescript - * // Access immutable properties - * const mediaType = task.derived.mediaType; - * const isCall = task.derived.isCall; - * - * // Access dynamic properties (always fresh) - * if (task.derived.isConsultInProgress) { - * showConsultUI(); - * } - * - * // Access UI controls (computed from state machine) - * - * ``` - */ - public get derived(): TaskDerivedState { - return { - ...this.immutableProps, - ...this.computeDynamicProps(), - uiControls: this.computeUIControls(), - }; - } - - /** - * Backward compatibility getter for taskUiControls. - * @deprecated Use task.derived.uiControls instead - * This provides the same data but computed fresh from state machine state. + * Get UI controls for task actions. + * Computed from state machine state and context. * * @example * ```typescript - * // Old way (deprecated) * const visible = task.taskUiControls.hold.visible; - * - * // New way (recommended) - * const visible = task.derived.uiControls.hold.visible; + * const enabled = task.taskUiControls.hold.enabled; * ``` */ public get taskUiControls(): TaskUIActions { @@ -403,108 +210,6 @@ export default abstract class Task extends EventEmitter implements ITask { return result as TaskUIActions; } - /** - * @deprecated Use `derived` instead for better clarity - */ - public get accessor(): TaskDerivedState { - return this.derived; - } - - /** - * Compute immutable properties once at task creation. - * These properties are based on initial task data and don't change. - */ - private computeImmutableProps(): TaskImmutableProps { - const interaction = this.data?.interaction ?? null; - const agentId = this.data?.agentId; - const mediaType = interaction?.mediaType ?? null; - const isCall = mediaType === MEDIA_TYPE_TELEPHONY; - const isChat = mediaType === 'chat'; - const isEmail = mediaType === 'email'; - const isDigitalChannel = Boolean(isChat || isEmail); - - return { - interactionId: this.data?.interactionId ?? null, - mediaType, - mediaChannel: interaction?.mediaChannel ?? null, - isCall, - isChat, - isEmail, - isDigitalChannel, - isSecondaryAgent: this.isSecondaryAgent(), - isSecondaryEpDnAgent: this.isSecondaryEpDnAgent(), - agentJoinTimestamp: agentId ? this.getAgentJoinTimestamp(agentId) : null, - }; - } - - /** - * Compute dynamic properties that can change as task state evolves. - * These are computed fresh on each access to reflect current state. - * - * HYBRID APPROACH: - * - Simple boolean flags (isOnHold, isConsultInProgress, isConferenceInProgress) - * are read from state machine context for consistency - * - Complex computed properties (participants lists, timestamps, etc.) - * are computed from this.data as before - */ - private computeDynamicProps(): TaskDynamicProps { - const agentId = this.data?.agentId; - - const consultStatus = agentId - ? this.getConsultStatus(agentId) - : this.data?.consultStatus ?? null; - const isConsultInitiated = consultStatus === 'CONSULT_INITIATED'; - const isConsultAccepted = consultStatus === 'CONSULT_ACCEPTED'; - const isBeingConsulted = - consultStatus === 'BEING_CONSULTED' || consultStatus === 'BEING_CONSULTED_ACCEPTED'; - const isConsultCompleted = consultStatus === 'CONSULT_COMPLETED'; - const isConsultInitiatedOrAccepted = - isConsultInitiated || isConsultAccepted || isBeingConsulted; - const isConsultInitiatedOrAcceptedOnly = isConsultInitiated || isConsultAccepted; - const isConsultInitiatedOrAcceptedOrBeingConsulted = - isConsultInitiated || isConsultAccepted || isBeingConsulted; - const isConsultReceived = isBeingConsulted; - const isConsultInitiatedAndAccepted = isConsultAccepted; - - // Derive state flags from state machine state - const state = this.stateMachineService?.state; - const isConsultInProgress = - state?.matches(TaskState.CONSULTING) ?? this.getIsConsultInProgress(); - const isOnHold = state?.matches(TaskState.HELD) ?? this.isInteractionOnHold(); - const isConferenceInProgress = - state?.matches(TaskState.CONFERENCING) ?? this.getIsConferenceInProgress(); - - return { - interaction: this.data?.interaction ?? null, - callDetails: this.getCallAssociatedDetails(), - consultStatus, - // Derived from state machine state, fallback to computed - isConsultInProgress, - isOnHold, - isHeld: isOnHold, - consultCallHeld: agentId ? this.findHoldStatus(MEDIA_TYPE_CONSULT, agentId) : false, - isConferenceInProgress, - // Complex properties still computed from this.data - conferenceParticipantsCount: this.getConferenceParticipantsCount(), - conferenceParticipants: agentId ? this.getConferenceParticipants(agentId) : [], - isCustomerInCall: this.getIsCustomerInCall(), - mpcState: agentId ? this.getConsultMPCState(agentId) : this.data?.interaction?.state ?? null, - consultingAgent: agentId ? this.getConsultingAgentParticipant(agentId) : null, - autoWrapupSeconds: this.getAutoWrapupSeconds(), - canCancelAutoWrapup: this.canCancelAutoWrapup(), - isConsultInitiated, - isConsultAccepted, - isBeingConsulted, - isConsultCompleted, - isConsultInitiatedOrAccepted, - isConsultInitiatedOrAcceptedOnly, - isConsultInitiatedOrAcceptedOrBeingConsulted, - isConsultReceived, - isConsultInitiatedAndAccepted, - isIncomingTask: agentId ? this.isIncomingTask(agentId) : false, - }; - } - /** * Initialize the state machine with custom action callbacks */ @@ -524,25 +229,9 @@ export default abstract class Task extends EventEmitter implements ITask { interactionId: taskData.interactionId, }); }, - onStartRonaTimer: (timeout) => { - this.startRonaTimer(timeout); - - return null; - }, - onStopRonaTimer: () => { - this.stopRonaTimer(); - }, - onStartAutoWrapupTimer: (timeout) => { - this.startAutoWrapupTimer(timeout); - - return null; - }, - onStopAutoWrapupTimer: () => { - this.stopAutoWrapupTimer(); - }, - onCleanupResources: () => { - this.cleanupResources(); - }, + onStopRonaTimer: () => {}, + onStopAutoWrapupTimer: () => {}, + onCleanupResources: () => {}, }; const customActions = createActionsWithCallbacks(callbacks); @@ -560,7 +249,7 @@ export default abstract class Task extends EventEmitter implements ITask { this.state = state; // Update UI controls based on current state - this.updateUIControlsFromState(); + this.computeUIControls(); }) .start(); } @@ -610,77 +299,6 @@ export default abstract class Task extends EventEmitter implements ITask { }; } - /** - * @deprecated Legacy method - no longer needed with computed UI controls - * Child classes no longer need to override this. - */ - protected updateUIControlsFromState(): void { - // No-op - UI controls are now computed via derived.uiControls - } - - /** - * Start RONA (Ring on No Answer) timer - */ - private startRonaTimer(timeout: number): void { - this.stopRonaTimer(); - this.ronaTimerId = setTimeout(() => { - LoggerProxy.warn('RONA timeout reached', { - module: CC_FILE, - method: 'startRonaTimer', - interactionId: this.data.interactionId, - }); - this.sendStateMachineEvent({type: TaskEvent.RONA}); - }, timeout); - } - - /** - * Stop RONA timer - */ - private stopRonaTimer(): void { - if (this.ronaTimerId) { - clearTimeout(this.ronaTimerId); - this.ronaTimerId = undefined; - } - } - - /** - * Start auto-wrapup timer - */ - private startAutoWrapupTimer(timeout: number): void { - this.stopAutoWrapupTimer(); - this.autoWrapupTimerId = setTimeout(() => { - LoggerProxy.log('Auto-wrapup timeout reached', { - module: CC_FILE, - method: 'startAutoWrapupTimer', - interactionId: this.data.interactionId, - }); - this.sendStateMachineEvent({type: TaskEvent.AUTO_WRAPUP}); - }, timeout); - } - - /** - * Stop auto-wrapup timer - */ - private stopAutoWrapupTimer(): void { - if (this.autoWrapupTimerId) { - clearTimeout(this.autoWrapupTimerId); - this.autoWrapupTimerId = undefined; - } - } - - /** - * Cleanup task resources (WebRTC, timers, etc.) - */ - private cleanupResources(): void { - this.stopRonaTimer(); - this.stopAutoWrapupTimer(); - LoggerProxy.log('Cleaning up task resources', { - module: CC_FILE, - method: 'cleanupResources', - interactionId: this.data.interactionId, - }); - } - /** * Stop the state machine service */ @@ -703,14 +321,6 @@ export default abstract class Task extends EventEmitter implements ITask { return oldData; } - /** - * @deprecated Legacy method - UI controls are now computed via derived.uiControls - * This method is kept for backward compatibility but does nothing. - */ - protected setUIControls() { - // No-op - UI controls are now computed automatically - } - /** * * @param methodName - The name of the method that is unsupported @@ -724,447 +334,6 @@ export default abstract class Task extends EventEmitter implements ITask { throw new Error(`Unsupported operation: ${methodName}`); } - /** - * @deprecated Legacy method - UI controls are now computed via derived.uiControls - * This method is kept for backward compatibility but does nothing. - * Child classes no longer need to call this method. - */ - protected updateTaskUiControls(): void { - // No-op - UI controls are now computed automatically from state machine - } - - private getCallAssociatedDetails(): Record | null { - const interaction = this.data?.interaction as Record | undefined; - - return interaction?.callAssociatedDetails ?? null; - } - - private getConsultingAgentParticipant(agentId?: string | null): Participant | null { - if (!agentId || !this.data?.interaction?.participants) { - return null; - } - - const participants = Object.values(this.data.interaction.participants) as Array; - - const consultingAgent = participants.find((participant) => { - if (!participant) { - return false; - } - const participantId = participant.id ?? participant.participantId; - const participantType = - typeof participant.pType === 'string' ? participant.pType.toUpperCase() : ''; - - if (participantId === agentId) { - return false; - } - - if (participant.hasLeft) { - return false; - } - - return participantType === 'AGENT'; - }); - - if (!consultingAgent) { - return null; - } - - return { - id: consultingAgent.id ?? consultingAgent.participantId ?? '', - name: consultingAgent.name ?? consultingAgent.id ?? consultingAgent.participantId ?? '', - pType: consultingAgent.pType, - }; - } - - private getAgentJoinTimestamp(agentId?: string | null): number | null { - if (!agentId) { - return null; - } - - const participant = this.data?.interaction?.participants?.[agentId]; - const joinTimestamp = participant?.joinTimestamp; - - return typeof joinTimestamp === 'number' ? joinTimestamp : null; - } - - private getAutoWrapupSeconds(): number | null { - if (!this.autoWrapup || typeof this.autoWrapup.getTimeLeftSeconds !== 'function') { - return null; - } - - try { - const timeLeft = this.autoWrapup.getTimeLeftSeconds(); - - return typeof timeLeft === 'number' && Number.isFinite(timeLeft) ? timeLeft : null; - } catch (error) { - LoggerProxy.warn('AutoWrapup getTimeLeftSeconds failed', { - module: CC_FILE, - method: 'getAutoWrapupSeconds', - error: (error as Error).message, - }); - - return null; - } - } - - private canCancelAutoWrapup(): boolean { - return Boolean( - (this.autoWrapup as {allowCancelAutoWrapup?: boolean} | undefined)?.allowCancelAutoWrapup - ); - } - - /** - * Determine if task is incoming for given agent - */ - public isIncomingTask(agentId: string): boolean { - const taskData = this.data; - const taskState = taskData?.interaction?.state; - const participants = taskData?.interaction?.participants; - const hasJoined = agentId && participants?.[agentId]?.hasJoined; - - return ( - !taskData?.wrapUpRequired && - !hasJoined && - (taskState === INTERACTION_STATE_NEW || - taskState === INTERACTION_STATE_CONSULT || - taskState === INTERACTION_STATE_CONNECTED || - taskState === INTERACTION_STATE_CONFERENCE) - ); - } - - /** - * Get consultation status derived from interaction state - */ - private getConsultStatus(agentId: string): string { - if (!agentId) { - return 'NO_CONSULTATION_IN_PROGRESS'; - } - if (!this.data?.interaction) { - return 'NO_CONSULTATION_IN_PROGRESS'; - } - - const state = this.getTaskStatus(agentId); - const participants = this.data.interaction.participants || {}; - const participant = participants[agentId]; - const beingConsulted = Boolean(participant?.isConsulted) || this.isSecondaryEpDnAgent(); - - if (state === TASK_STATE_CONSULT) { - return beingConsulted ? 'BEING_CONSULTED' : 'CONSULT_INITIATED'; - } - if (state === TASK_STATE_CONSULTING) { - return beingConsulted ? 'BEING_CONSULTED_ACCEPTED' : 'CONSULT_ACCEPTED'; - } - if (state === INTERACTION_STATE_CONNECTED) { - return 'CONNECTED'; - } - if (state === INTERACTION_STATE_CONFERENCE) { - return 'CONFERENCE'; - } - if (state === TASK_STATE_CONSULT_COMPLETED) { - return 'CONSULT_COMPLETED'; - } - - return 'NO_CONSULTATION_IN_PROGRESS'; - } - - private getTaskStatus(agentId: string): string { - if (!agentId) { - return 'NO_CONSULTATION_IN_PROGRESS'; - } - const interaction = this.data.interaction; - if (!interaction) { - return 'NO_CONSULTATION_IN_PROGRESS'; - } - - if (this.isSecondaryEpDnAgent()) { - if (interaction.state === INTERACTION_STATE_CONFERENCE) { - return INTERACTION_STATE_CONFERENCE; - } - - return TASK_STATE_CONSULTING; - } - - if ( - (interaction.state === INTERACTION_STATE_WRAPUP || - interaction.state === INTERACTION_STATE_POST_CALL) && - interaction.participants?.[agentId]?.consultState === CONSULT_STATE_COMPLETED - ) { - return TASK_STATE_CONSULT_COMPLETED; - } - - return this.getConsultMPCState(agentId); - } - - private getConsultMPCState(agentId: string): string { - const interaction = this.data.interaction; - const consultMediaResourceId = this.findMediaResourceId(MEDIA_TYPE_CONSULT); - - if ( - consultMediaResourceId && - interaction.participants?.[agentId]?.consultState && - interaction.state !== INTERACTION_STATE_WRAPUP && - interaction.state !== INTERACTION_STATE_POST_CALL - ) { - const consultState = interaction.participants[agentId]?.consultState; - - switch (consultState) { - case CONSULT_STATE_INITIATED: - return TASK_STATE_CONSULT; - case CONSULT_STATE_COMPLETED: - return interaction.state === INTERACTION_STATE_CONNECTED - ? INTERACTION_STATE_CONNECTED - : TASK_STATE_CONSULT_COMPLETED; - case CONSULT_STATE_CONFERENCING: - return INTERACTION_STATE_CONFERENCE; - default: - return TASK_STATE_CONSULTING; - } - } - - return interaction?.state || 'NO_CONSULTATION_IN_PROGRESS'; - } - - private isSecondaryAgent(): boolean { - const interaction = this.data.interaction; - - return ( - !!interaction.callProcessingDetails && - interaction.callProcessingDetails.relationshipType === RELATIONSHIP_TYPE_CONSULT && - !!interaction.callProcessingDetails.parentInteractionId && - interaction.callProcessingDetails.parentInteractionId !== interaction.interactionId - ); - } - - private isSecondaryEpDnAgent(): boolean { - return this.data.interaction.mediaType === MEDIA_TYPE_TELEPHONY && this.isSecondaryAgent(); - } - - public getIsConferenceInProgress(): boolean { - const interaction = this.data?.interaction; - const interactionId = this.data?.interactionId; - - if (!interaction?.media || !interactionId) { - return false; - } - - const mediaMainCall = interaction.media[interactionId]; - const participantsInMainCall = new Set(mediaMainCall?.participants); - const participants = interaction.participants ?? {}; - - let agentCount = 0; - participantsInMainCall.forEach((participantId: string) => { - const participant = participants[participantId]; - if ( - participant && - !EXCLUDED_PARTICIPANT_TYPES.includes(participant.pType) && - !participant.hasLeft - ) { - agentCount += 1; - } - }); - - return agentCount >= 2; - } - - public getConferenceParticipants(agentId?: string): Participant[] { - const participantsList: Participant[] = []; - const interaction = this.data?.interaction; - const interactionId = this.data?.interactionId; - - if (!interaction?.media || !interactionId) { - return participantsList; - } - - const mediaMainCall = interaction.media?.[interactionId]; - const participantsInMainCall = new Set(mediaMainCall?.participants ?? []); - const participants = interaction.participants ?? {}; - - participantsInMainCall.forEach((participantId: string) => { - const participant = participants[participantId]; - if ( - participant && - !EXCLUDED_PARTICIPANT_TYPES.includes(participant.pType) && - !participant.hasLeft && - participant.id !== agentId - ) { - participantsList.push({ - id: participant.id, - pType: participant.pType, - name: participant.name || participant.id, - }); - } - }); - - return participantsList; - } - - public getConferenceParticipantsCount(): number { - const interaction = this.data?.interaction; - const interactionId = this.data?.interactionId; - - if (!interaction?.media || !interactionId) { - return 0; - } - - const mediaMainCall = interaction.media?.[interactionId]; - const participantsInMainCall = new Set(mediaMainCall?.participants ?? []); - const participants = interaction.participants ?? {}; - - let count = 0; - participantsInMainCall.forEach((participantId: string) => { - const participant = participants[participantId]; - if ( - participant && - !EXCLUDED_PARTICIPANT_TYPES.includes(participant.pType) && - !participant.hasLeft - ) { - count += 1; - } - }); - - return count; - } - - public getIsCustomerInCall(): boolean { - const interaction = this.data?.interaction; - const interactionId = this.data?.interactionId; - - if (!interaction?.media || !interactionId) { - return false; - } - - const mediaMainCall = interaction.media[interactionId]; - const participantsInMainCall = new Set(mediaMainCall?.participants); - const participants = interaction.participants ?? {}; - - for (const participantId of participantsInMainCall) { - const participant = participants[participantId]; - if (participant && participant.pType === PARTICIPANT_TYPE_CUSTOMER && !participant.hasLeft) { - return true; - } - } - - return false; - } - - public getIsConsultInProgress(): boolean { - const media = this.data?.interaction?.media; - if (!media) { - return false; - } - - return Object.values(media).some((entry: any) => entry?.mType === MEDIA_TYPE_CONSULT); - } - - public isInteractionOnHold(): boolean { - const media = this.data?.interaction?.media; - if (!media) { - return false; - } - - return Object.values(media).some((entry: any) => Boolean(entry?.isHold)); - } - - private setMediaTypeForEpDn(mType: string): string { - if (this.isSecondaryEpDnAgent()) { - return 'mainCall'; - } - - return mType; - } - - public findMediaResourceId(mType: string): string { - const mediaEntries = this.data?.interaction?.media; - if (!mediaEntries) { - return ''; - } - - const normalizedType = this.setMediaTypeForEpDn(mType); - - for (const key of Object.keys(mediaEntries)) { - const media = (mediaEntries as Record)[key]; - if (media?.mType === normalizedType) { - return media.mediaResourceId ?? key; - } - } - - return ''; - } - - private isConsultOnHoldMPC(agentId: string): boolean { - const currentState = this.getConsultMPCState(agentId); - const isInConsultState = - currentState === TASK_STATE_CONSULT || currentState === TASK_STATE_CONSULTING; - const consultMediaResourceId = this.findMediaResourceId(MEDIA_TYPE_CONSULT); - let mediaEntry: any; - if (consultMediaResourceId) { - mediaEntry = (this.data.interaction.media as Record)[consultMediaResourceId]; - if (!mediaEntry) { - mediaEntry = Object.values(this.data.interaction.media as Record).find( - (entry) => entry?.mediaResourceId === consultMediaResourceId - ); - } - } - - const isConsultHold = consultMediaResourceId && mediaEntry?.isHold; - - return isInConsultState && !isConsultHold; - } - - public findHoldStatus(mType: string, agentId: string): boolean { - const interaction = this.data.interaction; - if (!agentId || !interaction?.media) { - return false; - } - - const normalizedType = this.setMediaTypeForEpDn(mType); - const mediaId = this.findMediaResourceId(normalizedType); - let mediaEntry = (interaction.media as Record)[mediaId]; - if (!mediaEntry) { - mediaEntry = Object.values(interaction.media as Record).find( - (entry) => entry?.mediaResourceId === mediaId - ); - } - - if (!mediaEntry) { - return false; - } - - if ( - normalizedType === 'mainCall' && - mediaEntry.participants?.includes(agentId) && - (this.isConsultOnHoldMPC(agentId) || - this.getConsultMPCState(agentId) === TASK_STATE_CONSULT_COMPLETED) - ) { - return true; - } - - if (normalizedType === MEDIA_TYPE_CONSULT && mediaEntry.participants?.includes(agentId)) { - return Boolean(mediaEntry.isHold); - } - - return Boolean(mediaEntry.isHold); - } - - public findHoldTimestamp(mType = 'mainCall'): number | null { - const interaction = this.data?.interaction; - const media = interaction?.media; - if (!media) { - return null; - } - - const normalizedType = this.setMediaTypeForEpDn(mType); - - for (const key of Object.keys(media)) { - const mediaEntry = (media as Record)[key]; - if (mediaEntry?.mType === normalizedType) { - return mediaEntry.holdTimestamp ?? null; - } - } - - return null; - } - /** * This method is used to update the task data. * @param updatedData - TaskData @@ -1178,7 +347,7 @@ export default abstract class Task extends EventEmitter implements ITask { public updateTaskData(updatedData: TaskData, shouldOverwrite = false): ITask { this.data = shouldOverwrite ? updatedData : this.reconcileData(this.data, updatedData); - this.setUIControls(); + this.computeUIControls(); return this; } diff --git a/packages/@webex/contact-center/src/services/task/digital/Digital.ts b/packages/@webex/contact-center/src/services/task/digital/Digital.ts index f4acfdbde1f..ef8234ddbae 100644 --- a/packages/@webex/contact-center/src/services/task/digital/Digital.ts +++ b/packages/@webex/contact-center/src/services/task/digital/Digital.ts @@ -1,17 +1,128 @@ import {CC_FILE, METHODS} from '../../../constants'; import {getErrorDetails} from '../../core/Utils'; import {IDigital, TaskResponse, TaskData} from '../types'; -import {CC_EVENTS} from '../../config/types'; import Task from '../Task'; -import routingContact from '../contact'; import LoggerProxy from '../../../logger-proxy'; import MetricsManager from '../../../metrics/MetricsManager'; import {METRIC_EVENT_NAMES} from '../../../metrics/constants'; +import {TaskState} from '../state-machine'; export default class Digital extends Task implements IDigital { - constructor(contact: ReturnType, data: TaskData) { - super(contact, data); - this.updateTaskUiControls({accept: [true, true]}); + /** + * Compute UI controls based on state machine state for digital channels. + * This method determines which buttons should be visible and enabled + * based on the current task state. + * + * @returns UI control states for all task actions + */ + protected computeUIControls(): import('../Task').TaskUIControls { + const state = this.stateMachineService?.state; + + if (!state) { + // Fallback if state machine not initialized + return super.computeUIControls(); + } + + // Determine current state + const isOffered = state.matches(TaskState.OFFERED); + const isConnected = state.matches(TaskState.CONNECTED); + const isWrappingUp = state.matches(TaskState.WRAPPING_UP); + const isTerminated = this.data.interaction?.isTerminated ?? false; + + // For digital channels, determine if task needs wrapup + const needsWrapup = isTerminated || isWrappingUp; + + return { + // Accept button: visible when task is offered + accept: { + visible: isOffered, + enabled: isOffered, + }, + + // Decline: not used in digital channels + decline: { + visible: false, + enabled: false, + }, + + // Hold: not used in digital channels + hold: { + visible: false, + enabled: false, + }, + + // Mute: not used in digital channels + mute: { + visible: false, + enabled: false, + }, + + // End button: visible when connected, not when wrapping up + end: { + visible: isConnected && !isWrappingUp, + enabled: isConnected && !isWrappingUp, + }, + + // Transfer button: visible when connected, not when wrapping up + transfer: { + visible: isConnected && !isWrappingUp, + enabled: isConnected && !isWrappingUp, + }, + + // Consult: not used in digital channels + consult: { + visible: false, + enabled: false, + }, + + // Consult transfer: not used in digital channels + consultTransfer: { + visible: false, + enabled: false, + }, + + // End consult: not used in digital channels + endConsult: { + visible: false, + enabled: false, + }, + + // Recording: not used in digital channels + recording: { + visible: false, + enabled: false, + }, + + // Conference: not used in digital channels + conference: { + visible: false, + enabled: false, + }, + + // Wrapup button: visible when task is terminated or in wrapup state + wrapup: { + visible: needsWrapup, + enabled: needsWrapup, + }, + + // Exit conference: not used in digital channels + exitConference: { + visible: false, + enabled: false, + }, + + // Transfer conference: not used in digital channels + transferConference: { + visible: false, + enabled: false, + }, + + // Merge to conference: not used in digital channels + mergeToConference: { + visible: false, + enabled: false, + }, + }; } /** @@ -81,63 +192,4 @@ export default class Digital extends Task implements IDigital { throw detailedError; } } - - protected setUIControls(): void { - const eventType = this.data.type; - - switch (eventType) { - case CC_EVENTS.AGENT_CONTACT_ASSIGNED: - // once accepted: enable transfer + end - this.updateTaskUiControls({ - accept: [false, false], - transfer: [true, true], - end: [true, true], - }); - break; - - case CC_EVENTS.AGENT_VTEAM_TRANSFERRED: - case CC_EVENTS.AGENT_BLIND_TRANSFERRED: - case CC_EVENTS.AGENT_WRAPUP: - // after transfer or end: enable wrapup - this.updateTaskUiControls({ - transfer: [false, false], - end: [false, false], - wrapup: [true, true], - }); - break; - - case CC_EVENTS.AGENT_CONTACT: - if (this.data.interaction.isTerminated) { - this.updateTaskUiControls({ - transfer: [false, false], - end: [false, false], - wrapup: [true, true], - }); - } else if (this.data.interaction.state === 'connected') { - this.updateTaskUiControls({ - accept: [false, false], - transfer: [true, true], - end: [true, true], - }); - } else if (this.data.interaction.state === 'new') { - this.updateTaskUiControls({ - accept: [true, true], - transfer: [false, false], - end: [false, false], - }); - } - break; - - case CC_EVENTS.AGENT_CONTACT_OFFER_RONA: - this.updateTaskUiControls({ - accept: [false, false], - transfer: [false, false], - end: [false, false], - }); - break; - - default: - break; - } - } } diff --git a/packages/@webex/contact-center/src/services/task/state-machine/actions.ts b/packages/@webex/contact-center/src/services/task/state-machine/actions.ts index 75555bb5a07..83ad8732cac 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/actions.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/actions.ts @@ -13,7 +13,7 @@ */ import {assign} from 'xstate'; -import {TaskContext, TaskState, TaskEventPayload, isEventOfType, TaskEvent} from './types'; +import {TaskContext, TaskEventPayload, isEventOfType, TaskEvent} from './types'; /** * Create initial context for a new task @@ -21,27 +21,19 @@ import {TaskContext, TaskState, TaskEventPayload, isEventOfType, TaskEvent} from export function createInitialContext(): TaskContext { return { taskData: null, - currentState: TaskState.IDLE, previousState: null, - mediaResourceId: null, consultInitiator: false, consultDestination: null, - consultDestinationType: null, consultDestinationAgentJoined: false, - consultMediaResourceId: null, conferenceInitiatorId: null, conferenceParticipants: [], maxConferenceParticipants: 10, participants: [], // DEPRECATED: Use conferenceParticipants instead - isPrimary: false, recordingActive: false, recordingPaused: false, wrapUpRequired: false, autoWrapupTimer: null, ronaTimer: null, - offeredAt: null, - connectedAt: null, - endedAt: null, }; } @@ -57,7 +49,6 @@ export const actions = { if (isEventOfType(event, TaskEvent.OFFER) || isEventOfType(event, TaskEvent.OFFER_CONSULT)) { return { taskData: event.taskData, - offeredAt: Date.now(), }; } @@ -71,7 +62,6 @@ export const actions = { if (isEventOfType(event, TaskEvent.ASSIGN)) { return { taskData: event.taskData, - connectedAt: Date.now(), }; } if (isEventOfType(event, TaskEvent.CONSULT_CREATED)) { @@ -97,7 +87,6 @@ export const actions = { if (isEventOfType(event, TaskEvent.CONSULT)) { return { consultDestination: event.destination, - consultDestinationType: event.destinationType, }; } @@ -163,9 +152,7 @@ export const actions = { }, ], consultDestination: null, - consultDestinationType: null, consultDestinationAgentJoined: false, - consultMediaResourceId: null, }; }), @@ -228,24 +215,6 @@ export const actions = { participants: [], }), - /** - * Set hold state - */ - setHoldState: assign((context, event) => { - if (isEventOfType(event, TaskEvent.HOLD)) { - return { - mediaResourceId: event.mediaResourceId, - }; - } - if (isEventOfType(event, TaskEvent.UNHOLD)) { - return { - mediaResourceId: event.mediaResourceId, - }; - } - - return {}; - }), - /** * Set recording state */ @@ -264,28 +233,11 @@ export const actions = { return {}; }), - /** - * Update state tracking - */ - updateState: assign((context) => { - return { - previousState: context.currentState, - }; - }), - - /** - * Mark task as ended - */ - markEnded: assign({ - endedAt: Date.now(), - }), - /** * Clear consult state */ clearConsultState: assign({ consultDestination: null, - consultDestinationType: null, consultDestinationAgentJoined: false, }), diff --git a/packages/@webex/contact-center/src/services/task/state-machine/types.ts b/packages/@webex/contact-center/src/services/task/state-machine/types.ts index d28558346b2..ecffdd5b568 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/types.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/types.ts @@ -133,18 +133,12 @@ export interface TaskContext { taskData: TaskData | null; // State tracking - currentState: TaskState; previousState: TaskState | null; - // Media tracking - mediaResourceId: string | null; - // Consult tracking consultInitiator: boolean; consultDestination: string | null; - consultDestinationType: 'agent' | 'queue' | 'entryPoint' | null; consultDestinationAgentJoined: boolean; - consultMediaResourceId: string | null; // Conference tracking conferenceInitiatorId: string | null; @@ -152,8 +146,6 @@ export interface TaskContext { maxConferenceParticipants: number; participants: string[]; // DEPRECATED: Use conferenceParticipants instead - isPrimary: boolean; - // Recording tracking recordingActive: boolean; recordingPaused: boolean; @@ -164,11 +156,6 @@ export interface TaskContext { // RONA tracking ronaTimer: number | null; - - // Timestamps - offeredAt: number | null; - connectedAt: number | null; - endedAt: number | null; } /** diff --git a/packages/@webex/contact-center/src/services/task/voice/Voice.ts b/packages/@webex/contact-center/src/services/task/voice/Voice.ts index 30bbbeee6f2..e0ae3c71a20 100644 --- a/packages/@webex/contact-center/src/services/task/voice/Voice.ts +++ b/packages/@webex/contact-center/src/services/task/voice/Voice.ts @@ -1,9 +1,5 @@ -import {CC_FILE, METHODS, TASK_FILE} from '../../../constants'; -import { - getErrorDetails, - buildConsultConferenceParamData, - generateTaskErrorObject, -} from '../../core/Utils'; +import {CC_FILE, METHODS} from '../../../constants'; +import {getErrorDetails} from '../../core/Utils'; import routingContact from '../contact'; import { ConsultPayload, @@ -26,30 +22,6 @@ export default class Voice extends Task implements IVoice { private isEndCallEnabled: boolean; private isEndConsultEnabled: boolean; - /** - * UI Control state constants for better readability. - * These represent [visibility, enabled] tuples used by updateTaskUiControls(). - * - * @example - * // Button is shown and clickable - * this.updateTaskUiControls({ accept: Voice.VISIBLE_ENABLED }); - * - * // Button is shown but grayed out/disabled - * this.updateTaskUiControls({ transfer: Voice.VISIBLE_DISABLED }); - * - * // Button is not displayed at all - * this.updateTaskUiControls({ consult: Voice.HIDDEN }); - */ - - /** Button is visible and enabled (clickable) - [true, true] */ - private static readonly VISIBLE_ENABLED = [true, true] as [boolean, boolean]; - - /** Button is visible but disabled (grayed out) - [true, false] */ - private static readonly VISIBLE_DISABLED = [true, false] as [boolean, boolean]; - - /** Button is hidden (not displayed) - [false, false] */ - private static readonly HIDDEN = [false, false] as [boolean, boolean]; - constructor( contact: ReturnType, data: TaskData, @@ -61,73 +33,12 @@ export default class Voice extends Task implements IVoice { this.isEndConsultEnabled = callOptions.isEndConsultEnabled ?? true; } - /** - * Helper method to create UI control state based on visibility and enabled status. - * Returns [visibility, enabled] tuple for use with updateTaskUiControls(). - * - * @param visible - Whether the button should be displayed - * @param enabled - Whether the button should be clickable (only applies if visible) - * @returns Tuple of [visibility, enabled] booleans - * - * @example - * // Dynamic control based on state - * this.updateTaskUiControls({ - * hold: this.uiControl(true, this.canPerformOperation('hold')), - * end: this.uiControl(this.isEndCallEnabled, this.isEndCallEnabled) - * }); - */ - private uiControl(visible: boolean, enabled: boolean): [boolean, boolean] { - if (!visible) return Voice.HIDDEN; - - return enabled ? Voice.VISIBLE_ENABLED : Voice.VISIBLE_DISABLED; - } - - /** - * Helper method to check if an operation is allowed in the current state - */ - private canPerformOperation(operation: string): boolean { - const state = this.stateMachineService?.state; - if (!state) { - return false; - } - - switch (operation) { - case 'hold': - return state.matches(TaskState.CONNECTED); - case 'resume': - return state.matches(TaskState.HELD); - case 'consult': - return state.matches(TaskState.CONNECTED) || state.matches(TaskState.HELD); - case 'conference': - return state.matches(TaskState.CONSULTING) && state.context.consultDestinationAgentJoined; - case 'transfer': - return ( - state.matches(TaskState.CONNECTED) || - state.matches(TaskState.HELD) || - state.matches(TaskState.CONSULTING) - ); - case 'exitConference': - return state.matches(TaskState.CONFERENCING); - default: - return false; - } - } - - /** - * Helper to check if consult destination agent has joined - */ - private isConsultAgentJoined(): boolean { - const context = this.stateMachineService?.state?.context; - - return context?.consultDestinationAgentJoined || false; - } - /** * Compute UI controls based on state machine state. * This replaces the old updateUIControlsFromState() method. * Returns plain objects instead of mutating taskUiControls. */ - protected override computeUIControls(): import('../Task').TaskUIControls { + protected computeUIControls(): import('../Task').TaskUIControls { const state = this.stateMachineService?.state; if (!state) { @@ -776,316 +687,4 @@ export default class Voice extends Task implements IVoice { throw detailedError; } } - - /** - * Performs a consult transfer - * @param consultTransferPayload - Optional payload for consult transfer - * @returns Promise - * @throws Error - */ - public async consultTransfer( - consultTransferPayload?: ConsultTransferPayLoad - ): Promise { - try { - LoggerProxy.info('Performing consult transfer', { - module: CC_FILE, - method: METHODS.CONSULT_TRANSFER, - interactionId: this.data.interactionId, - }); - - let payload: ConsultTransferPayLoad; - if (consultTransferPayload) { - payload = consultTransferPayload; - } else if (this.data.destAgentId) { - payload = { - to: this.data.destAgentId, - destinationType: CONSULT_TRANSFER_DESTINATION_TYPE.AGENT, - }; - } else { - throw new Error('No destination specified for consult transfer'); - } - - this.metricsManager.timeEvent([ - METRIC_EVENT_NAMES.TASK_TRANSFER_SUCCESS, - METRIC_EVENT_NAMES.TASK_TRANSFER_FAILED, - ]); - - const result = await this.contact.consultTransfer({ - interactionId: this.data.interactionId, - data: payload, - }); - - this.metricsManager.trackEvent( - METRIC_EVENT_NAMES.TASK_TRANSFER_SUCCESS, - { - taskId: this.data.interactionId, - destination: payload.to, - destinationType: payload.destinationType, - isConsultTransfer: true, - ...MetricsManager.getCommonTrackingFieldForAQMResponse(result), - }, - ['operational', 'behavioral', 'business'] - ); - - return result; - } catch (error) { - const {error: detailedError} = getErrorDetails(error, METHODS.CONSULT_TRANSFER, CC_FILE); - this.metricsManager.trackEvent( - METRIC_EVENT_NAMES.TASK_TRANSFER_FAILED, - { - taskId: this.data.interactionId, - error: error.toString(), - ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed(error.details || {}), - }, - ['operational', 'behavioral', 'business'] - ); - throw detailedError; - } - } - - /** - * Initiates a consult conference (merge consult call with main call) - * Creates a three-way conference between the agent, customer, and consulted party - * @returns Promise - * @throws Error - */ - public async consultConference(): Promise { - // Extract consultation conference data from task data (used in both try and catch) - const consultationData = { - agentId: this.data.agentId, - destAgentId: this.data.destAgentId, - destinationType: this.data.destinationType || 'agent', - }; - - try { - LoggerProxy.info(`Initiating consult conference to ${consultationData.destAgentId}`, { - module: TASK_FILE, - method: METHODS.CONSULT_CONFERENCE, - interactionId: this.data.interactionId, - }); - - const paramsDataForConferenceV2 = buildConsultConferenceParamData( - consultationData, - this.data.interactionId - ); - - const response = await this.contact.consultConference({ - interactionId: paramsDataForConferenceV2.interactionId, - data: paramsDataForConferenceV2.data, - }); - - // Track success metrics (following consultTransfer pattern) - this.metricsManager.trackEvent( - METRIC_EVENT_NAMES.TASK_CONFERENCE_START_SUCCESS, - { - taskId: this.data.interactionId, - destination: paramsDataForConferenceV2.data.to, - destinationType: paramsDataForConferenceV2.data.destinationType, - agentId: paramsDataForConferenceV2.data.agentId, - ...MetricsManager.getCommonTrackingFieldForAQMResponse(response), - }, - ['operational', 'behavioral', 'business'] - ); - - LoggerProxy.log(`Consult conference started successfully`, { - module: TASK_FILE, - method: METHODS.CONSULT_CONFERENCE, - interactionId: this.data.interactionId, - }); - - return response; - } catch (error) { - const err = generateTaskErrorObject(error, METHODS.CONSULT_CONFERENCE, TASK_FILE); - const taskErrorProps = { - trackingId: err.data?.trackingId, - errorMessage: err.data?.message, - errorType: err.data?.errorType, - errorData: err.data?.errorData, - reasonCode: err.data?.reasonCode, - }; - - // Track failure metrics (following consultTransfer pattern) - // Build conference data for error tracking using extracted data - const failedParamsData = buildConsultConferenceParamData( - consultationData, - this.data.interactionId - ); - - this.metricsManager.trackEvent( - METRIC_EVENT_NAMES.TASK_CONFERENCE_START_FAILED, - { - taskId: this.data.interactionId, - destination: failedParamsData.data.to, - destinationType: failedParamsData.data.destinationType, - agentId: failedParamsData.data.agentId, - error: error.toString(), - ...taskErrorProps, - ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed(error.details || {}), - }, - ['operational', 'behavioral', 'business'] - ); - - LoggerProxy.error(`Failed to start consult conference`, { - module: TASK_FILE, - method: METHODS.CONSULT_CONFERENCE, - interactionId: this.data.interactionId, - }); - - throw err; - } - } - - /** - * Exits from an ongoing conference - * Exits the agent from the conference, leaving the customer and consulted party connected - * @returns Promise - * @throws Error - */ - public async exitConference(): Promise { - try { - LoggerProxy.info(`Exiting consult conference`, { - module: TASK_FILE, - method: METHODS.EXIT_CONFERENCE, - interactionId: this.data.interactionId, - }); - - // Validate that interaction ID exists - if (!this.data.interactionId) { - throw new Error('Invalid interaction ID'); - } - - const response = await this.contact.exitConference({ - interactionId: this.data.interactionId, - }); - - // Track success metrics (following consultTransfer pattern) - this.metricsManager.trackEvent( - METRIC_EVENT_NAMES.TASK_CONFERENCE_END_SUCCESS, - { - taskId: this.data.interactionId, - ...MetricsManager.getCommonTrackingFieldForAQMResponse(response), - }, - ['operational', 'behavioral', 'business'] - ); - - LoggerProxy.log(`Consult conference exited successfully`, { - module: TASK_FILE, - method: METHODS.EXIT_CONFERENCE, - interactionId: this.data.interactionId, - }); - - return response; - } catch (error) { - const err = generateTaskErrorObject(error, METHODS.EXIT_CONFERENCE, TASK_FILE); - const taskErrorProps = { - trackingId: err.data?.trackingId, - errorMessage: err.data?.message, - errorType: err.data?.errorType, - errorData: err.data?.errorData, - reasonCode: err.data?.reasonCode, - }; - - // Track failure metrics (following consultTransfer pattern) - this.metricsManager.trackEvent( - METRIC_EVENT_NAMES.TASK_CONFERENCE_END_FAILED, - { - taskId: this.data.interactionId, - error: error.toString(), - ...taskErrorProps, - ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed(error.details || {}), - }, - ['operational', 'behavioral', 'business'] - ); - - LoggerProxy.error(`Failed to exit consult conference`, { - module: TASK_FILE, - method: METHODS.EXIT_CONFERENCE, - interactionId: this.data.interactionId, - }); - - throw err; - } - } - - /** - * Transfers the conference to another participant - * Moves the entire conference (including all participants) to a new agent, - * while the current agent exits and goes to wrapup - * @returns Promise - * @throws Error - */ - public async transferConference(): Promise { - try { - LoggerProxy.info(`Transferring conference`, { - module: TASK_FILE, - method: METHODS.TRANSFER_CONFERENCE, - interactionId: this.data.interactionId, - }); - - // Validate that interaction ID exists - if (!this.data.interactionId) { - throw new Error('Invalid interaction ID'); - } - - const response = await this.contact.conferenceTransfer({ - interactionId: this.data.interactionId, - }); - - // Track success metrics (following consultTransfer pattern) - this.metricsManager.trackEvent( - METRIC_EVENT_NAMES.TASK_CONFERENCE_TRANSFER_SUCCESS, - { - taskId: this.data.interactionId, - ...MetricsManager.getCommonTrackingFieldForAQMResponse(response), - }, - ['operational', 'behavioral', 'business'] - ); - - LoggerProxy.log(`Conference transferred successfully`, { - module: TASK_FILE, - method: METHODS.TRANSFER_CONFERENCE, - interactionId: this.data.interactionId, - }); - - return response; - } catch (error) { - const err = generateTaskErrorObject(error, METHODS.TRANSFER_CONFERENCE, TASK_FILE); - const taskErrorProps = { - trackingId: err.data?.trackingId, - errorMessage: err.data?.message, - errorType: err.data?.errorType, - errorData: err.data?.errorData, - reasonCode: err.data?.reasonCode, - }; - - // Track failure metrics (following consultTransfer pattern) - this.metricsManager.trackEvent( - METRIC_EVENT_NAMES.TASK_CONFERENCE_TRANSFER_FAILED, - { - taskId: this.data.interactionId, - error: error.toString(), - ...taskErrorProps, - ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed(error.details || {}), - }, - ['operational', 'behavioral', 'business'] - ); - - LoggerProxy.error(`Failed to transfer conference`, { - module: TASK_FILE, - method: METHODS.TRANSFER_CONFERENCE, - interactionId: this.data.interactionId, - }); - - throw err; - } - } - - /** - * Toggles mute/unmute for the local audio stream during a WebRTC task - * @returns Promise - * @throws Error - */ - public async toggleMute(): Promise { - super.unsupportedMethodError(METHODS.TOGGLE_MUTE); - } } diff --git a/packages/@webex/contact-center/src/services/task/voice/WebRTC.ts b/packages/@webex/contact-center/src/services/task/voice/WebRTC.ts index 24996275e55..72ce96952da 100644 --- a/packages/@webex/contact-center/src/services/task/voice/WebRTC.ts +++ b/packages/@webex/contact-center/src/services/task/voice/WebRTC.ts @@ -5,7 +5,7 @@ import routingContact from '../contact'; import {TaskData, TaskResponse, TASK_EVENTS, IWebRTC} from '../types'; import Voice from './Voice'; import WebCallingService from '../../WebCallingService'; -import {CC_EVENTS} from '../../config/types'; +import {TaskState} from '../state-machine'; import MetricsManager from '../../../metrics/MetricsManager'; import {METRIC_EVENT_NAMES} from '../../../metrics/constants'; import LoggerProxy from '../../../logger-proxy'; @@ -21,7 +21,6 @@ export default class WebRTC extends Voice implements IWebRTC { callOptions: {isEndCallEnabled?: boolean; isEndConsultEnabled?: boolean} = {} ) { super(contact, data, callOptions); - this.updateTaskUiControls({accept: [true, true], decline: [true, true]}); this.webCallingService = webCallingService; this.registerWebCallListeners(); } @@ -35,88 +34,78 @@ export default class WebRTC extends Voice implements IWebRTC { }; /** - * This method is used to set the UI controls for the specific type of task + * Compute UI controls for WebRTC tasks. + * Extends Voice UI controls with WebRTC-specific behavior: + * + * 1. Accept/Decline buttons: + * - Visible when task is offered (OFFERED or OFFERED_CONSULT states) + * - Hidden when consulted and in consulting state + * - Hidden when call is terminated + * + * 2. Mute button: + * - Visible when connected or when consulting (if this agent is consulted) + * - Disabled when call is held (can't mute a held call) + * - Hidden during wrapup + * + * WebRTC handles audio client-side, so these controls differ from telephony tasks. + * + * @returns UI control states for all task actions */ - protected setUIControls(): void { - super.setUIControls(); - switch (this.data.type) { - // show accept/decline only on normal web call offers - case CC_EVENTS.AGENT_OFFER_CONTACT: - case CC_EVENTS.AGENT_OFFER_CONSULT: - this.updateTaskUiControls({ - accept: [true, true], - decline: [true, true], - }); - break; - - // on consult accepted hide accept/decline and show mute - case CC_EVENTS.AGENT_CONSULTING: - if (this.data.isConsulted) { - this.updateTaskUiControls({ - accept: [false, false], - decline: [false, false], - }); - } - this.updateTaskUiControls({ - mute: [true, true], - }); - break; - - // when consult ends (and we were the recipient) hide mute - case CC_EVENTS.AGENT_CONSULT_ENDED: - if (this.data.isConsulted) { - this.updateTaskUiControls({ - mute: [false, false], - accept: [false, false], - decline: [false, false], - }); - } - break; + protected computeUIControls(): import('../Task').TaskUIControls { + // Get base controls from Voice class + const controls = super.computeUIControls(); - // hide accept/decline when RONA occurs - case CC_EVENTS.AGENT_CONTACT_OFFER_RONA: - this.updateTaskUiControls({ - accept: [false, false], - decline: [false, false], - }); - break; - - // hide accept/decline when contact is ended by the external user - case CC_EVENTS.CONTACT_ENDED: - if (this.data.interaction.state === 'new') { - this.updateTaskUiControls({accept: [false, false], decline: [false, false]}); - } - break; - - case CC_EVENTS.AGENT_CONTACT_ASSIGNED: - this.updateTaskUiControls({ - mute: [true, true], - }); - break; - - case CC_EVENTS.AGENT_CONTACT_HELD: - // disable mute when call is held - this.updateTaskUiControls({ - mute: [true, false], - }); - break; - - case CC_EVENTS.AGENT_CONTACT_UNHELD: - // enable mute when call is resumed - this.updateTaskUiControls({ - mute: [true, true], - }); - break; - - default: - // hide mute when wrapup is active - if (this.taskUiControls.wrapup.visible) { - this.updateTaskUiControls({ - mute: [false, false], - }); - } - break; + const state = this.stateMachineService?.state; + if (!state) { + return controls; } + + // Determine current state + const isOffered = state.matches(TaskState.OFFERED) || state.matches(TaskState.OFFERED_CONSULT); + const isConnected = state.matches(TaskState.CONNECTED); + const isHeld = state.matches(TaskState.HELD); + const isConsulting = state.matches(TaskState.CONSULTING); + const isWrappingUp = state.matches(TaskState.WRAPPING_UP); + + // Check if this agent is the consulted party + const isConsultedAgent = this.data.isConsulted ?? false; + + // Check if call is terminated (ended externally while still offered) + const isTerminated = this.data.interaction?.isTerminated ?? false; + + // WebRTC-specific accept/decline logic + // Accept and decline should be visible when: + // - Task is offered (OFFERED or OFFERED_CONSULT state) + // - AND not terminated + // - AND (not consulting OR not the consulted agent) + const showAcceptDecline = isOffered && !isTerminated && (!isConsulting || !isConsultedAgent); + + controls.accept = { + visible: showAcceptDecline, + enabled: showAcceptDecline, + }; + + controls.decline = { + visible: showAcceptDecline, + enabled: showAcceptDecline, + }; + + // WebRTC-specific mute button logic + // Mute should be visible when: + // - Call is connected (active) OR + // - Call is consulting AND this agent is the consulted one + const showMute = isConnected || (isConsulting && isConsultedAgent); + + // Mute should be enabled when: + // - Visible AND not held AND not wrapping up + const enableMute = showMute && !isHeld && !isWrappingUp; + + controls.mute = { + visible: showMute, + enabled: enableMute, + }; + + return controls; } /** From d322be97bd4759af3629dd9d9cc04f8fa2eb349f Mon Sep 17 00:00:00 2001 From: arungane Date: Tue, 18 Nov 2025 22:50:17 -0500 Subject: [PATCH 07/14] fix(contact center): remove guard and un used action --- .../contact-center/src/services/task/Task.ts | 18 +- .../task/state-machine/TaskStateMachine.ts | 134 +++---- .../services/task/state-machine/actions.ts | 97 ----- .../src/services/task/state-machine/guards.ts | 348 ++---------------- .../src/services/task/state-machine/index.ts | 5 +- .../src/services/task/state-machine/types.ts | 51 +-- .../src/services/task/voice/Voice.ts | 73 +++- 7 files changed, 189 insertions(+), 537 deletions(-) diff --git a/packages/@webex/contact-center/src/services/task/Task.ts b/packages/@webex/contact-center/src/services/task/Task.ts index e8e55ae5039..d4b8f2b9bda 100644 --- a/packages/@webex/contact-center/src/services/task/Task.ts +++ b/packages/@webex/contact-center/src/services/task/Task.ts @@ -168,6 +168,22 @@ export default abstract class Task extends EventEmitter implements ITask { }); } + /** + * Cancel any in-progress auto wrap-up timer. + * Base implementation just clears the timer reference so subclasses inherit the behavior. + */ + public cancelAutoWrapupTimer(): void { + if (this.autoWrapup) { + this.autoWrapup.clear(); + this.autoWrapup = undefined; + LoggerProxy.log('Auto wrap-up timer cancelled', { + module: CC_FILE, + method: 'cancelAutoWrapupTimer', + interactionId: this.data?.interactionId, + }); + } + } + // Voice tasks use holdResume(), but provide separate methods for interface compliance public async hold(): Promise { this.unsupportedMethodError('hold'); @@ -229,8 +245,6 @@ export default abstract class Task extends EventEmitter implements ITask { interactionId: taskData.interactionId, }); }, - onStopRonaTimer: () => {}, - onStopAutoWrapupTimer: () => {}, onCleanupResources: () => {}, }; diff --git a/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts b/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts index 745a542a9c1..370b2482369 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts @@ -7,7 +7,6 @@ import {createMachine, StateMachine} from 'xstate'; import {TaskContext, TaskState, TaskEvent, TaskEventPayload} from './types'; -import {guards} from './guards'; import {actions, createInitialContext} from './actions'; /** @@ -33,8 +32,6 @@ export const taskStateMachineConfig = { }, [TaskState.OFFERED]: { - entry: ['startRonaTimer'], - exit: ['stopRonaTimer'], on: { [TaskEvent.ACCEPT]: { target: TaskState.CONNECTED, @@ -56,8 +53,6 @@ export const taskStateMachineConfig = { }, [TaskState.OFFERED_CONSULT]: { - entry: ['startRonaTimer'], - exit: ['stopRonaTimer'], on: { [TaskEvent.ACCEPT]: { target: TaskState.CONSULTING, @@ -77,13 +72,11 @@ export const taskStateMachineConfig = { [TaskState.CONNECTED]: { on: { [TaskEvent.HOLD]: { - target: TaskState.HELD, - cond: 'canHold', - actions: ['setHoldState', 'updateState'], + target: TaskState.HOLD_INITIATING, + actions: ['updateState'], }, [TaskEvent.CONSULT]: { - target: TaskState.CONSULTING, - cond: 'canConsult', + target: TaskState.CONSULT_INITIATING, actions: ['setConsultInitiator', 'setConsultDestination', 'updateState'], }, [TaskEvent.CONSULT_CREATED]: { @@ -92,24 +85,16 @@ export const taskStateMachineConfig = { }, [TaskEvent.TRANSFER]: { target: TaskState.WRAPPING_UP, - cond: 'canTransfer', actions: ['updateState'], }, [TaskEvent.END]: { target: TaskState.WRAPPING_UP, actions: ['markEnded', 'updateState'], }, - [TaskEvent.CONTACT_ENDED]: [ - { - target: TaskState.WRAPPING_UP, - cond: 'wrapupRequired', - actions: ['markEnded', 'updateState'], - }, - { - target: TaskState.COMPLETED, - actions: ['markEnded', 'updateState'], - }, - ], + [TaskEvent.CONTACT_ENDED]: { + target: TaskState.WRAPPING_UP, + actions: ['markEnded', 'updateState'], + }, [TaskEvent.PAUSE_RECORDING]: { actions: ['setRecordingState'], }, @@ -119,21 +104,31 @@ export const taskStateMachineConfig = { }, }, + [TaskState.HOLD_INITIATING]: { + on: { + [TaskEvent.HOLD_SUCCESS]: { + target: TaskState.HELD, + actions: ['setHoldState', 'updateState'], + }, + [TaskEvent.HOLD_FAILED]: { + target: TaskState.CONNECTED, + actions: ['updateState'], + }, + }, + }, + [TaskState.HELD]: { on: { [TaskEvent.UNHOLD]: { - target: TaskState.CONNECTED, - cond: 'canResume', - actions: ['setHoldState', 'updateState'], + target: TaskState.RESUME_INITIATING, + actions: ['updateState'], }, [TaskEvent.CONSULT]: { - target: TaskState.CONSULTING, - cond: 'canConsult', + target: TaskState.CONSULT_INITIATING, actions: ['setConsultInitiator', 'setConsultDestination', 'updateState'], }, [TaskEvent.TRANSFER]: { target: TaskState.WRAPPING_UP, - cond: 'canTransfer', actions: ['updateState'], }, [TaskEvent.END]: { @@ -143,6 +138,32 @@ export const taskStateMachineConfig = { }, }, + [TaskState.RESUME_INITIATING]: { + on: { + [TaskEvent.UNHOLD_SUCCESS]: { + target: TaskState.CONNECTED, + actions: ['setHoldState', 'updateState'], + }, + [TaskEvent.UNHOLD_FAILED]: { + target: TaskState.HELD, + actions: ['updateState'], + }, + }, + }, + + [TaskState.CONSULT_INITIATING]: { + on: { + [TaskEvent.CONSULT_SUCCESS]: { + target: TaskState.CONSULTING, + actions: ['updateState'], + }, + [TaskEvent.CONSULT_FAILED]: { + target: TaskState.CONNECTED, + actions: ['updateState'], + }, + }, + }, + [TaskState.CONSULTING]: { on: { [TaskEvent.CONSULTING_ACTIVE]: { @@ -150,17 +171,14 @@ export const taskStateMachineConfig = { }, [TaskEvent.START_CONFERENCE]: { target: TaskState.CONFERENCING, - cond: 'canStartConference', actions: ['initializeConference', 'updateState'], }, [TaskEvent.MERGE_TO_CONFERENCE]: { target: TaskState.CONFERENCING, - cond: 'canMergeConsultToConference', actions: ['initializeConference', 'updateState'], }, [TaskEvent.CONFERENCE_START]: { target: TaskState.CONFERENCING, - cond: 'canStartConference', actions: ['setConferencing', 'updateState'], }, [TaskEvent.CONSULT_END]: { @@ -173,31 +191,22 @@ export const taskStateMachineConfig = { }, [TaskEvent.TRANSFER]: { target: TaskState.WRAPPING_UP, - cond: 'canTransfer', actions: ['updateState'], }, [TaskEvent.END]: { target: TaskState.WRAPPING_UP, actions: ['markEnded', 'clearConsultState', 'updateState'], }, - [TaskEvent.CONTACT_ENDED]: [ - { - target: TaskState.WRAPPING_UP, - cond: 'wrapupRequired', - actions: ['markEnded', 'clearConsultState', 'updateState'], - }, - { - target: TaskState.COMPLETED, - actions: ['markEnded', 'clearConsultState', 'updateState'], - }, - ], + [TaskEvent.CONTACT_ENDED]: { + target: TaskState.WRAPPING_UP, + actions: ['markEnded', 'clearConsultState', 'updateState'], + }, }, }, [TaskState.CONFERENCING]: { on: { [TaskEvent.PARTICIPANT_JOIN]: { - cond: 'canAddToConference', actions: ['addParticipant'], }, [TaskEvent.PARTICIPANT_LEAVE]: { @@ -205,50 +214,31 @@ export const taskStateMachineConfig = { }, [TaskEvent.EXIT_CONFERENCE]: { target: TaskState.WRAPPING_UP, - cond: 'canExitConference', actions: ['clearConferencing', 'markEnded', 'updateState'], }, [TaskEvent.TRANSFER_CONFERENCE]: { target: TaskState.WRAPPING_UP, - cond: 'canTransferConference', actions: ['clearConferencing', 'updateState'], }, - [TaskEvent.CONFERENCE_END]: [ - { - target: TaskState.CONNECTED, - cond: 'shouldEndConference', - actions: ['clearConferencing', 'updateState'], - }, - { - target: TaskState.WRAPPING_UP, - actions: ['clearConferencing', 'markEnded', 'updateState'], - }, - ], + [TaskEvent.CONFERENCE_END]: { + target: TaskState.WRAPPING_UP, + actions: ['clearConferencing', 'markEnded', 'updateState'], + }, [TaskEvent.END]: { target: TaskState.WRAPPING_UP, actions: ['markEnded', 'clearConferencing', 'updateState'], }, - [TaskEvent.CONTACT_ENDED]: [ - { - target: TaskState.WRAPPING_UP, - cond: 'wrapupRequired', - actions: ['markEnded', 'clearConferencing', 'updateState'], - }, - { - target: TaskState.COMPLETED, - actions: ['markEnded', 'clearConferencing', 'updateState'], - }, - ], + [TaskEvent.CONTACT_ENDED]: { + target: TaskState.WRAPPING_UP, + actions: ['markEnded', 'clearConferencing', 'updateState'], + }, }, }, [TaskState.WRAPPING_UP]: { - entry: ['startAutoWrapupTimer'], - exit: ['stopAutoWrapupTimer'], on: { [TaskEvent.WRAPUP]: { target: TaskState.COMPLETED, - cond: 'canWrapup', actions: ['updateState'], }, [TaskEvent.AUTO_WRAPUP]: { @@ -284,7 +274,6 @@ export function createTaskStateMachine(): StateMachine< any > { return createMachine(taskStateMachineConfig, { - guards, actions, }); } @@ -299,7 +288,6 @@ export function createTaskStateMachineWithActions( customActions: Record ): StateMachine { return createMachine(taskStateMachineConfig, { - guards, actions: { ...actions, ...customActions, diff --git a/packages/@webex/contact-center/src/services/task/state-machine/actions.ts b/packages/@webex/contact-center/src/services/task/state-machine/actions.ts index 83ad8732cac..a765bd6e1cd 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/actions.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/actions.ts @@ -7,7 +7,6 @@ * NOTE: These actions are meant to be used within XState assign() or as standalone action functions. * Event emission and UI control updates will be handled by the Task/Voice classes that use this state machine. * - * TODO: Timer implementations (startRonaTimer, startAutoWrapupTimer) will be added to Task/Voice classes later. * TODO: Event emission logic will be integrated with existing Task EventEmitter pattern. * TODO: Resource cleanup logic will be added to handle WebRTC and other resources. */ @@ -31,9 +30,6 @@ export function createInitialContext(): TaskContext { participants: [], // DEPRECATED: Use conferenceParticipants instead recordingActive: false, recordingPaused: false, - wrapUpRequired: false, - autoWrapupTimer: null, - ronaTimer: null, }; } @@ -240,62 +236,6 @@ export const actions = { consultDestination: null, consultDestinationAgentJoined: false, }), - - /** - * Stop RONA timer - */ - stopRonaTimer: assign({ - ronaTimer: null, - }), - - /** - * Stop auto-wrapup timer - */ - stopAutoWrapupTimer: assign({ - autoWrapupTimer: null, - }), -}; - -/** - * Side-effect action creators - * These are functions that will be called by the state machine to perform side effects. - * They don't modify context directly, but trigger external effects like: - * - Starting timers - * - Logging - * - Emitting events (handled by Task/Voice class) - * - Cleaning up resources - */ -export const sideEffects = { - /** - * Start RONA (Ring On No Answer) timer - * This should be implemented by the caller to start an actual timer that sends RONA event after timeout - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - startRonaTimer: (context: TaskContext, event: TaskEventPayload) => { - // Implementation will be provided by Task/Voice class - // The class will start a timer and send RONA event when it expires - }, - - /** - * Start auto-wrapup timer - * Implementation provided by Task/Voice class - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - startAutoWrapupTimer: (context: TaskContext, event: TaskEventPayload) => { - // Implementation will be provided by Task/Voice class - }, - - /** - * Cleanup resources on task end - * Implementation provided by Task/Voice class to: - * - Stop timers - * - Release WebRTC resources - * - Clean up event listeners - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - cleanupResources: (context: TaskContext, event: TaskEventPayload) => { - // Implementation will be provided by Task/Voice class - }, }; /** @@ -315,10 +255,6 @@ export interface ActionCallbacks { onTaskConferenceEnded?: (taskData: any) => void; onTaskEnd?: (taskData: any) => void; onTaskWrappedup?: (taskData: any) => void; - onStartRonaTimer?: (timeout: number) => number | null; - onStopRonaTimer?: (timerId: number | null) => void; - onStartAutoWrapupTimer?: (timeout: number) => number | null; - onStopAutoWrapupTimer?: (timerId: number | null) => void; onCleanupResources?: () => void; } @@ -363,39 +299,6 @@ export function createActionsWithCallbacks(callbacks: ActionCallbacks) { callbacks.onTaskWrappedup?.(context.taskData); }, - // Timer actions - startRonaTimer: () => { - if (callbacks.onStartRonaTimer) { - const timerId = callbacks.onStartRonaTimer(30000); // 30 seconds default - if (timerId !== null) { - // Store timer ID in context via assign action - return assign({ronaTimer: timerId}); - } - } - - return undefined; - }, - stopRonaTimer: (context: TaskContext) => { - if (callbacks.onStopRonaTimer && context.ronaTimer) { - callbacks.onStopRonaTimer(context.ronaTimer); - } - }, - startAutoWrapupTimer: () => { - if (callbacks.onStartAutoWrapupTimer) { - const timerId = callbacks.onStartAutoWrapupTimer(60000); // 60 seconds default - if (timerId !== null) { - return assign({autoWrapupTimer: timerId}); - } - } - - return undefined; - }, - stopAutoWrapupTimer: (context: TaskContext) => { - if (callbacks.onStopAutoWrapupTimer && context.autoWrapupTimer) { - callbacks.onStopAutoWrapupTimer(context.autoWrapupTimer); - } - }, - // Cleanup action cleanupResources: () => { callbacks.onCleanupResources?.(); diff --git a/packages/@webex/contact-center/src/services/task/state-machine/guards.ts b/packages/@webex/contact-center/src/services/task/state-machine/guards.ts index a51f1263c0b..c967633a49d 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/guards.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/guards.ts @@ -4,348 +4,46 @@ * Guard functions that determine if a state transition is allowed. * These functions validate the current context before allowing transitions. * - * NOTE: Guards currently only use context parameter. TaskEventPayload is imported - * for future use if guards need to inspect event data for more complex validations. - * TODO: If guards need event data in the future, add event parameter back to guard signatures. + * All guards now use a consistent object-based parameter structure for better + * maintainability, type safety, and extensibility. */ import {StateValue} from 'xstate'; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import {TaskContext, TaskState, TaskEventPayload} from './types'; +import {TaskContext, TaskEventPayload} from './types'; /** - * Guard functions for state machine transitions + * Parameters passed to all guard functions */ -export const guards = { - /** - * Can accept if in OFFERED or OFFERED_CONSULT state - */ - canAccept: (context: TaskContext, event: any, meta: {state: {value: StateValue}}): boolean => { - const state = meta.state.value as TaskState; - - return state === TaskState.OFFERED || state === TaskState.OFFERED_CONSULT; - }, - - /** - * Can only hold if connected and not already on hold - */ - canHold: (context: TaskContext, event: any, meta: {state: {value: StateValue}}): boolean => { - const state = meta.state.value as TaskState; - - // Can only hold if in CONNECTED state (not already HELD) - return state === TaskState.CONNECTED; - }, - - /** - * Can only resume if currently held - */ - canResume: (context: TaskContext, event: any, meta: {state: {value: StateValue}}): boolean => { - const state = meta.state.value as TaskState; - - // Can only resume if in HELD state - return state === TaskState.HELD; - }, - - /** - * Can only consult if not already in consult/conference - */ - canConsult: (context: TaskContext, event: any, meta: {state: {value: StateValue}}): boolean => { - const state = meta.state.value as TaskState; - - // Can consult if in CONNECTED or HELD state (CONFERENCING is a separate state) - return state === TaskState.CONNECTED || state === TaskState.HELD; - }, - - /** - * Can only start conference if consult destination agent has joined - */ - canStartConference: ( - context: TaskContext, - event: any, - meta: {state: {value: StateValue}} - ): boolean => { - const state = meta.state.value as TaskState; - if (state !== TaskState.CONSULTING) { - return false; - } - - // Destination agent must have joined - if (!context.consultDestinationAgentJoined) { - return false; - } - - return true; - }, - - /** - * Can only transfer if not in certain states - */ - canTransfer: (context: TaskContext, event: any, meta: {state: {value: StateValue}}): boolean => { - const state = meta.state.value as TaskState; - // Can transfer from CONNECTED, HELD, or CONSULTING - - return ( - state === TaskState.CONNECTED || state === TaskState.HELD || state === TaskState.CONSULTING - ); - }, - - /** - * Can only exit conference if actually in conference - */ - canExitConference: ( - context: TaskContext, - event: any, - meta: {state: {value: StateValue}} - ): boolean => { - const state = meta.state.value as TaskState; - - return state === TaskState.CONFERENCING; - }, - - /** - * Can only wrapup if in WRAPPING_UP state - */ - canWrapup: (context: TaskContext, event: any, meta: {state: {value: StateValue}}): boolean => { - const state = meta.state.value as TaskState; - - return state === TaskState.WRAPPING_UP; - }, - - /** - * Check if current task is from a consult offer - * Now derived from state instead of context flag - */ - isConsulted: (context: TaskContext, event: any, meta: {state: {value: StateValue}}): boolean => { - const state = meta.state.value as TaskState; - - return state === TaskState.CONSULTING; - }, - - /** - * Check if conference is ending (less than 2 participants) - */ - isConferenceEnding: ( - context: TaskContext, - event: any, - meta: {state: {value: StateValue}} - ): boolean => { - const state = meta.state.value as TaskState; - if (state !== TaskState.CONFERENCING) { - return false; - } - - // Conference ends when fewer than 2 participants remain - return context.participants.length < 2; - }, - - /** - * Can merge consult to conference if in CONSULTING state and destination agent has joined - */ - canMergeConsultToConference: ( - context: TaskContext, - event: any, - meta: {state: {value: StateValue}} - ): boolean => { - const state = meta.state.value as TaskState; - - return ( - state === TaskState.CONSULTING && - context.consultDestinationAgentJoined && - context.conferenceParticipants.length === 0 - ); - }, - - /** - * Can add participant to conference if in CONFERENCING state and not at max capacity - */ - canAddToConference: ( - context: TaskContext, - event: any, - meta: {state: {value: StateValue}} - ): boolean => { - const state = meta.state.value as TaskState; - - return ( - state === TaskState.CONFERENCING && - context.conferenceParticipants.length < context.maxConferenceParticipants - ); - }, - - /** - * Can transfer conference if initiator and in CONFERENCING state - * Note: event parameter would be needed to check agentId, but keeping signature consistent - */ - canTransferConference: ( - context: TaskContext, - event: any, - meta: {state: {value: StateValue}} - ): boolean => { - const state = meta.state.value as TaskState; - if (state !== TaskState.CONFERENCING) { - return false; - } - - // In future, we'd check if the requesting agent is the initiator via event data - // For now, check if there's an initiator set - return context.conferenceInitiatorId !== null; - }, - - /** - * Should end conference if fewer than 2 agents remain - */ - shouldEndConference: (context: TaskContext): boolean => { - const agentCount = context.conferenceParticipants.filter((p) => p.type === 'AGENT').length; +export interface GuardParams { + /** Task context containing all task-related data */ + context: TaskContext; + /** Current state information */ + state?: {value: StateValue}; + /** Event that triggered the guard check (optional, for future use) */ + event?: TaskEventPayload; +} - return agentCount < 2; - }, +/** + * Guard function type - all guards follow this signature + */ +export type GuardFunction = (params: GuardParams) => boolean; +/** + * Guard functions for state machine transitions + * Only includes guards that are actively used in the codebase + */ +export const guards = { /** * Check if recording is active */ - recordingActive: (context: TaskContext): boolean => { + recordingActive: ({context}: GuardParams): boolean => { return context.recordingActive && !context.recordingPaused; }, /** * Check if recording is paused */ - recordingPaused: (context: TaskContext): boolean => { + recordingPaused: ({context}: GuardParams): boolean => { return context.recordingActive && context.recordingPaused; }, - - /** - * Check if wrapup is required - */ - wrapupRequired: (context: TaskContext): boolean => { - return context.wrapUpRequired; - }, - - /** - * Check if in connected state - */ - isConnected: (context: TaskContext, event: any, meta: {state: {value: StateValue}}): boolean => { - const state = meta.state.value as TaskState; - - return state === TaskState.CONNECTED; - }, - - /** - * Check if in held state - */ - isHeld: (context: TaskContext, event: any, meta: {state: {value: StateValue}}): boolean => { - const state = meta.state.value as TaskState; - - return state === TaskState.HELD; - }, - - /** - * Check if in consulting state - */ - isConsulting: (context: TaskContext, event: any, meta: {state: {value: StateValue}}): boolean => { - const state = meta.state.value as TaskState; - - return state === TaskState.CONSULTING; - }, - - /** - * Check if in conferencing state - */ - isConferencing: ( - context: TaskContext, - event: any, - meta: {state: {value: StateValue}} - ): boolean => { - const state = meta.state.value as TaskState; - - return state === TaskState.CONFERENCING; - }, - - /** - * Check if user is consult initiator - */ - isConsultInitiator: (context: TaskContext): boolean => { - return context.consultInitiator; - }, - - /** - * Check if interaction state is 'new' (for CONTACT_ENDED event) - */ - isInteractionStateNew: (context: TaskContext): boolean => { - if (!context.taskData || !context.taskData.interaction) { - return false; - } - - return context.taskData.interaction.state === 'new'; - }, }; - -/** - * Helper function to check if operation is allowed in current state - * This can be used from outside the state machine - */ -export function canPerformOperation( - context: TaskContext, - operation: keyof typeof guards, - state: {value: StateValue} -): boolean { - const guard = guards[operation]; - if (!guard) { - return false; - } - - return guard(context, null, {state}); -} - -/** - * Validate state transition - * Returns true if transition from current state to target state is valid - */ -export function isValidTransition(currentState: TaskState, targetState: TaskState): boolean { - // Define valid transitions matrix - const validTransitions: Record = { - [TaskState.IDLE]: [TaskState.OFFERED, TaskState.OFFERED_CONSULT], - [TaskState.OFFERED]: [TaskState.CONNECTED, TaskState.TERMINATED], - [TaskState.OFFERED_CONSULT]: [TaskState.CONSULTING, TaskState.TERMINATED], - [TaskState.CONNECTED]: [ - TaskState.HELD, - TaskState.CONSULTING, - TaskState.WRAPPING_UP, - TaskState.TERMINATED, - TaskState.CONSULT_INITIATED, // NOT IMPLEMENTED: MPC state - ], - [TaskState.HELD]: [TaskState.CONNECTED, TaskState.CONSULTING], - [TaskState.CONSULTING]: [ - TaskState.CONNECTED, - TaskState.CONFERENCING, - TaskState.WRAPPING_UP, - TaskState.TERMINATED, - TaskState.CONSULT_COMPLETED, // NOT IMPLEMENTED: MPC state - ], - [TaskState.CONFERENCING]: [ - TaskState.CONNECTED, - TaskState.WRAPPING_UP, - TaskState.TERMINATED, - TaskState.POST_CALL, // NOT IMPLEMENTED: Post-call state - ], - [TaskState.WRAPPING_UP]: [TaskState.COMPLETED], - [TaskState.COMPLETED]: [], - [TaskState.TERMINATED]: [], - // NOT IMPLEMENTED: MPC (Multi-Party Conference) states - [TaskState.CONSULT_INITIATED]: [ - TaskState.CONSULTING, - TaskState.CONSULT_COMPLETED, - TaskState.TERMINATED, - ], - [TaskState.CONSULT_COMPLETED]: [TaskState.CONNECTED, TaskState.WRAPPING_UP], - // NOT IMPLEMENTED: Post-call state - [TaskState.POST_CALL]: [TaskState.WRAPPING_UP, TaskState.COMPLETED], - // NOT IMPLEMENTED: Parked state - [TaskState.PARKED]: [TaskState.CONNECTED, TaskState.TERMINATED], - // NOT IMPLEMENTED: Monitoring state - [TaskState.MONITORING]: [TaskState.IDLE, TaskState.TERMINATED], - }; - - const allowedTargets = validTransitions[currentState] || []; - - return allowedTargets.includes(targetState); -} diff --git a/packages/@webex/contact-center/src/services/task/state-machine/index.ts b/packages/@webex/contact-center/src/services/task/state-machine/index.ts index 9d1dff89d7b..e5c5f54e88e 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/index.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/index.ts @@ -17,8 +17,9 @@ export type {TaskContext, TaskEventPayload, TaskStateMachineConfig, UIControls} // Guards export {guards} from './guards'; -export type {TaskAction, TaskGuard} from './types'; +export type {GuardParams, GuardFunction} from './guards'; +export type {TaskAction} from './types'; // Actions -export {actions, createInitialContext, sideEffects, createActionsWithCallbacks} from './actions'; +export {actions, createInitialContext, createActionsWithCallbacks} from './actions'; export type {ActionCallbacks} from './actions'; diff --git a/packages/@webex/contact-center/src/services/task/state-machine/types.ts b/packages/@webex/contact-center/src/services/task/state-machine/types.ts index ecffdd5b568..96efe3205eb 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/types.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/types.ts @@ -15,12 +15,20 @@ export enum TaskState { OFFERED = 'OFFERED', OFFERED_CONSULT = 'OFFERED_CONSULT', CONNECTED = 'CONNECTED', + + // Intermediate states for async operations + HOLD_INITIATING = 'HOLD_INITIATING', HELD = 'HELD', + RESUME_INITIATING = 'RESUME_INITIATING', + + CONSULT_INITIATING = 'CONSULT_INITIATING', CONSULTING = 'CONSULTING', + CONFERENCING = 'CONFERENCING', WRAPPING_UP = 'WRAPPING_UP', COMPLETED = 'COMPLETED', TERMINATED = 'TERMINATED', + // NOT IMPLEMENTED: MPC (Multi-Party Conference) states CONSULT_INITIATED = 'CONSULT_INITIATED', CONSULT_COMPLETED = 'CONSULT_COMPLETED', @@ -47,10 +55,15 @@ export enum TaskEvent { // Hold/Resume events HOLD = 'HOLD', + HOLD_SUCCESS = 'HOLD_SUCCESS', + HOLD_FAILED = 'HOLD_FAILED', UNHOLD = 'UNHOLD', + UNHOLD_SUCCESS = 'UNHOLD_SUCCESS', + UNHOLD_FAILED = 'UNHOLD_FAILED', // Consult events CONSULT = 'CONSULT', + CONSULT_SUCCESS = 'CONSULT_SUCCESS', CONSULT_CREATED = 'CONSULT_CREATED', CONSULTING_ACTIVE = 'CONSULTING_ACTIVE', CONSULT_END = 'CONSULT_END', @@ -149,13 +162,6 @@ export interface TaskContext { // Recording tracking recordingActive: boolean; recordingPaused: boolean; - - // Wrapup tracking - wrapUpRequired: boolean; - autoWrapupTimer: number | null; - - // RONA tracking - ronaTimer: number | null; } /** @@ -168,12 +174,17 @@ export type TaskEventPayload = | {type: TaskEvent.DECLINE} | {type: TaskEvent.ASSIGN; taskData: TaskData} | {type: TaskEvent.HOLD; mediaResourceId: string} + | {type: TaskEvent.HOLD_SUCCESS; mediaResourceId: string} + | {type: TaskEvent.HOLD_FAILED; reason?: string; mediaResourceId: string} | {type: TaskEvent.UNHOLD; mediaResourceId: string} + | {type: TaskEvent.UNHOLD_SUCCESS; mediaResourceId: string} + | {type: TaskEvent.UNHOLD_FAILED; reason?: string; mediaResourceId: string} | { type: TaskEvent.CONSULT; destination: string; destinationType: 'agent' | 'queue' | 'entryPoint'; } + | {type: TaskEvent.CONSULT_SUCCESS; taskData?: TaskData} | {type: TaskEvent.CONSULT_CREATED; taskData: TaskData} | {type: TaskEvent.CONSULTING_ACTIVE; consultDestinationAgentJoined: boolean} | {type: TaskEvent.CONSULT_END} @@ -247,8 +258,6 @@ export interface TaskStateMachineConfig { export enum TaskAction { // Entry/Exit actions INITIALIZE_TASK = 'initializeTask', - START_RONA_TIMER = 'startRonaTimer', - STOP_RONA_TIMER = 'stopRonaTimer', EMIT_TASK_INCOMING = 'emitTaskIncoming', EMIT_TASK_ASSIGNED = 'emitTaskAssigned', EMIT_TASK_HOLD = 'emitTaskHold', @@ -260,8 +269,6 @@ export enum TaskAction { EMIT_TASK_CONFERENCE_ENDED = 'emitTaskConferenceEnded', EMIT_TASK_END = 'emitTaskEnd', EMIT_TASK_WRAPPEDUP = 'emitTaskWrappedup', - START_AUTO_WRAPUP_TIMER = 'startAutoWrapupTimer', - STOP_AUTO_WRAPUP_TIMER = 'stopAutoWrapupTimer', CLEANUP_RESOURCES = 'cleanupResources', // Context updates @@ -275,25 +282,3 @@ export enum TaskAction { SET_RECORDING_STATE = 'setRecordingState', UPDATE_TIMESTAMP = 'updateTimestamp', } - -/** - * Guard condition types - */ -export enum TaskGuard { - CAN_ACCEPT = 'canAccept', - CAN_HOLD = 'canHold', - CAN_RESUME = 'canResume', - CAN_CONSULT = 'canConsult', - CAN_START_CONFERENCE = 'canStartConference', - CAN_MERGE_TO_CONFERENCE = 'canMergeConsultToConference', - CAN_ADD_TO_CONFERENCE = 'canAddToConference', - CAN_TRANSFER = 'canTransfer', - CAN_EXIT_CONFERENCE = 'canExitConference', - CAN_TRANSFER_CONFERENCE = 'canTransferConference', - SHOULD_END_CONFERENCE = 'shouldEndConference', - CAN_WRAPUP = 'canWrapup', - IS_CONSULTED = 'isConsulted', - IS_CONFERENCE_ENDING = 'isConferenceEnding', - RECORDING_ACTIVE = 'recordingActive', - RECORDING_PAUSED = 'recordingPaused', -} diff --git a/packages/@webex/contact-center/src/services/task/voice/Voice.ts b/packages/@webex/contact-center/src/services/task/voice/Voice.ts index e0ae3c71a20..e260405407c 100644 --- a/packages/@webex/contact-center/src/services/task/voice/Voice.ts +++ b/packages/@webex/contact-center/src/services/task/voice/Voice.ts @@ -16,7 +16,7 @@ import Task from '../Task'; import LoggerProxy from '../../../logger-proxy'; import MetricsManager from '../../../metrics/MetricsManager'; import {METRIC_EVENT_NAMES} from '../../../metrics/constants'; -import {TaskState, guards} from '../state-machine'; +import {TaskState, TaskEvent, guards} from '../state-machine'; export default class Voice extends Task implements IVoice { private isEndCallEnabled: boolean; @@ -241,6 +241,15 @@ export default class Voice extends Task implements IVoice { } } + // Send initiating event to transition to intermediate state + if (this.stateMachineService) { + const initiatingEvent = shouldHold ? TaskEvent.HOLD : TaskEvent.UNHOLD; + this.stateMachineService.send({ + type: initiatingEvent, + mediaResourceId: this.data.mediaResourceId, + }); + } + LoggerProxy.info(`${shouldHold ? 'Holding' : 'Resuming'} task`, { module: CC_FILE, method: METHODS.HOLD_RESUME, @@ -259,6 +268,15 @@ export default class Voice extends Task implements IVoice { interactionId: this.data.interactionId, data: {mediaResourceId: this.data.mediaResourceId}, }); + + // Send success event to complete the transition + if (this.stateMachineService) { + this.stateMachineService.send({ + type: TaskEvent.HOLD_SUCCESS, + mediaResourceId: this.data.mediaResourceId, + }); + } + this.metricsManager.trackEvent( successEvt, { @@ -280,6 +298,15 @@ export default class Voice extends Task implements IVoice { interactionId: this.data.interactionId, data: {mediaResourceId: this.data.mediaResourceId}, }); + + // Send success event to complete the transition + if (this.stateMachineService) { + this.stateMachineService.send({ + type: TaskEvent.UNHOLD_SUCCESS, + mediaResourceId: this.data.mediaResourceId, + }); + } + this.metricsManager.trackEvent( successEvt, { @@ -300,6 +327,16 @@ export default class Voice extends Task implements IVoice { return response; } catch (error) { + // Send failure event to transition back to previous state + if (this.stateMachineService) { + const failureEvent = shouldHold ? TaskEvent.HOLD_FAILED : TaskEvent.UNHOLD_FAILED; + this.stateMachineService.send({ + type: failureEvent, + reason: error.toString(), + mediaResourceId: this.data.mediaResourceId, + }); + } + const {error: detailedError} = getErrorDetails(error, 'holdResume', CC_FILE); this.metricsManager.trackEvent( failedEvt, @@ -332,8 +369,8 @@ export default class Voice extends Task implements IVoice { */ public async pauseRecording(): Promise { // Validate recording is active - const context = this.stateMachineService?.state?.context; - if (context && !guards.recordingActive(context)) { + const state = this.stateMachineService?.state; + if (state && !guards.recordingActive({context: state.context})) { const error = new Error('Recording is not active or already paused'); LoggerProxy.error('Pause recording operation not allowed', { module: CC_FILE, @@ -399,8 +436,8 @@ export default class Voice extends Task implements IVoice { resumeRecordingPayload?: ResumeRecordingPayload ): Promise { // Validate recording is paused - const context = this.stateMachineService?.state?.context; - if (context && !guards.recordingPaused(context)) { + const state = this.stateMachineService?.state; + if (state && !guards.recordingPaused({context: state.context})) { const error = new Error('Recording is not paused'); LoggerProxy.error('Resume recording operation not allowed', { module: CC_FILE, @@ -488,6 +525,15 @@ export default class Voice extends Task implements IVoice { throw error; } + // Send initiating event to transition to CONSULT_INITIATING state + if (this.stateMachineService) { + this.stateMachineService.send({ + type: TaskEvent.CONSULT, + destination: consultPayload.to, + destinationType: consultPayload.destinationType as 'queue' | 'agent' | 'entryPoint', + }); + } + try { LoggerProxy.info(`Starting consult`, { module: CC_FILE, @@ -502,6 +548,15 @@ export default class Voice extends Task implements IVoice { interactionId: this.data.interactionId, data: consultPayload, }); + + // Send success event to transition to CONSULTING state + if (this.stateMachineService) { + this.stateMachineService.send({ + type: TaskEvent.CONSULT_SUCCESS, + taskData: result.data, + }); + } + this.metricsManager.trackEvent( METRIC_EVENT_NAMES.TASK_CONSULT_START_SUCCESS, { @@ -521,6 +576,14 @@ export default class Voice extends Task implements IVoice { return result; } catch (error) { + // Send failure event to transition back to previous state + if (this.stateMachineService) { + this.stateMachineService.send({ + type: TaskEvent.CONSULT_FAILED, + reason: error.toString(), + }); + } + const {error: detailedError} = getErrorDetails(error, 'consult', CC_FILE); this.metricsManager.trackEvent( METRIC_EVENT_NAMES.TASK_CONSULT_START_FAILED, From 870ba6b9d2a35cd8d77280ce5043d56efb7ac6a6 Mon Sep 17 00:00:00 2001 From: arungane Date: Thu, 20 Nov 2025 23:30:36 -0500 Subject: [PATCH 08/14] fix(contact-center): use the context and move the uicontrol logic --- package.json | 2 +- packages/@webex/contact-center/package.json | 4 +- .../contact-center/src/services/task/Task.ts | 201 +++----- .../src/services/task/TaskManager.ts | 124 ----- .../src/services/task/digital/Digital.ts | 128 +----- .../task/state-machine/TaskStateMachine.ts | 433 +++++++++--------- .../services/task/state-machine/actions.ts | 262 ++++++----- .../src/services/task/state-machine/index.ts | 11 +- .../src/services/task/state-machine/types.ts | 67 +-- .../task/state-machine/uiControlsComputer.ts | 313 +++++++++++++ .../contact-center/src/services/task/types.ts | 96 ++-- .../src/services/task/voice/Voice.ts | 149 +----- .../src/services/task/voice/WebRTC.ts | 82 +--- yarn.lock | 55 ++- 14 files changed, 898 insertions(+), 1029 deletions(-) create mode 100644 packages/@webex/contact-center/src/services/task/state-machine/uiControlsComputer.ts diff --git a/package.json b/package.json index 25e0300feea..253b08af779 100644 --- a/package.json +++ b/package.json @@ -210,7 +210,7 @@ "standard-version": "^9.1.1", "terser-webpack-plugin": "^4.2.3", "ts-jest": "^29.0.3", - "typescript": "^4.7.4", + "typescript": "^5.4.5", "uuid": "^3.3.2", "wd": "^1.14.0", "wdio-chromedriver-service": "^7.3.2", diff --git a/packages/@webex/contact-center/package.json b/packages/@webex/contact-center/package.json index 4380b4ed4e1..d7a619065a9 100644 --- a/packages/@webex/contact-center/package.json +++ b/packages/@webex/contact-center/package.json @@ -54,7 +54,7 @@ "@webex/webex-core": "workspace:*", "jest-html-reporters": "3.0.11", "lodash": "^4.17.21", - "xstate": "^4.38.0" + "xstate": "5.24.0" }, "devDependencies": { "@babel/core": "^7.22.11", @@ -80,6 +80,6 @@ "jest-junit": "13.0.0", "prettier": "2.5.1", "typedoc": "^0.25.0", - "typescript": "4.9.5" + "typescript": "5.4.5" } } diff --git a/packages/@webex/contact-center/src/services/task/Task.ts b/packages/@webex/contact-center/src/services/task/Task.ts index d4b8f2b9bda..8b4614715c8 100644 --- a/packages/@webex/contact-center/src/services/task/Task.ts +++ b/packages/@webex/contact-center/src/services/task/Task.ts @@ -1,6 +1,7 @@ import {EventEmitter} from 'events'; import {CallId} from '@webex/calling/dist/types/common/types'; -import {interpret, Interpreter} from 'xstate'; +import {createActor} from 'xstate'; +import type {ActorRefFrom} from 'xstate'; import { ITask, TaskData, @@ -8,9 +9,9 @@ import { WrapupPayLoad, TaskId, TransferPayLoad, - TaskButtonControl, - TaskUIActions, DESTINATION_TYPE, + TASK_EVENTS, + TaskUIControls, } from './types'; import {CC_FILE} from '../../constants'; import {getErrorDetails} from '../core/Utils'; @@ -22,11 +23,18 @@ import { createTaskStateMachineWithActions, createActionsWithCallbacks, TaskState, - TaskContext, TaskEventPayload, + type TaskStateMachine, type ActionCallbacks, + type UIControlConfig, + type TaskContext, } from './state-machine'; import AutoWrapup from './AutoWrapup'; +import { + computeUIControls, + getDefaultUIControls, + haveUIControlsChanged, +} from './state-machine/uiControlsComputer'; /** * Participant information for UI display @@ -37,39 +45,6 @@ export type Participant = { pType?: string; }; -/** - * UI control state for a single task action button. - * Represents visibility and enabled state for UI components. - */ -export interface UIControlState { - /** Whether the button should be displayed */ - visible: boolean; - /** Whether the button should be clickable (only applies if visible) */ - enabled: boolean; -} - -/** - * UI controls for all task actions. - * Computed from state machine state and context. - */ -export interface TaskUIControls { - accept: UIControlState; - decline: UIControlState; - hold: UIControlState; - mute: UIControlState; - end: UIControlState; - transfer: UIControlState; - consult: UIControlState; - consultTransfer: UIControlState; - endConsult: UIControlState; - recording: UIControlState; - conference: UIControlState; - wrapup: UIControlState; - exitConference: UIControlState; - transferConference: UIControlState; - mergeToConference: UIControlState; -} - /** * @deprecated Use Participant instead */ @@ -78,86 +53,75 @@ export type TaskAccessorParticipant = Participant; export default abstract class Task extends EventEmitter implements ITask { protected contact: ReturnType; protected metricsManager: MetricsManager; - public stateMachineService?: Interpreter; + public stateMachineService?: ActorRefFrom; public data: TaskData; public webCallMap: Record; public state: any; - - constructor(contact: ReturnType, data: TaskData) { + private lastState: TaskState | null = null; + protected currentUiControls: TaskUIControls; + protected uiControlConfig: UIControlConfig; + + constructor( + contact: ReturnType, + data: TaskData, + uiControlConfig: UIControlConfig + ) { super(); this.contact = contact; this.data = data; + this.uiControlConfig = uiControlConfig; this.metricsManager = MetricsManager.getInstance(); this.webCallMap = {}; + this.currentUiControls = getDefaultUIControls(); this.initializeStateMachine(); } // Properties from ITask interface public autoWrapup?: AutoWrapup; - // Abstract method that all child classes must implement + // Abstract methods that all child classes must implement public abstract accept(): Promise; // Voice-specific methods with default implementations that throw errors // Voice class will override these with actual implementations public async decline(): Promise { this.unsupportedMethodError('decline'); - - return Promise.reject(new Error('decline not supported for this channel type')); } public async pauseRecording(): Promise { this.unsupportedMethodError('pauseRecording'); - - return Promise.reject(new Error('pauseRecording not supported for this channel type')); } public async resumeRecording(): Promise { this.unsupportedMethodError('resumeRecording'); - - return Promise.reject(new Error('resumeRecording not supported for this channel type')); } public async consult(): Promise { this.unsupportedMethodError('consult'); - - return Promise.reject(new Error('consult not supported for this channel type')); } public async endConsult(): Promise { this.unsupportedMethodError('endConsult'); - - return Promise.reject(new Error('endConsult not supported for this channel type')); } public async consultTransfer(): Promise { this.unsupportedMethodError('consultTransfer'); - - return Promise.reject(new Error('consultTransfer not supported for this channel type')); } public async consultConference(): Promise { this.unsupportedMethodError('consultConference'); - - return Promise.reject(new Error('consultConference not supported for this channel type')); } public async exitConference(): Promise { this.unsupportedMethodError('exitConference'); - - return Promise.reject(new Error('exitConference not supported for this channel type')); } public async transferConference(): Promise { this.unsupportedMethodError('transferConference'); - - return Promise.reject(new Error('transferConference not supported for this channel type')); } public async toggleMute(): Promise { this.unsupportedMethodError('toggleMute'); - - return Promise.reject(new Error('toggleMute not supported for this channel type')); } public unregisterWebCallListeners(): void { @@ -187,43 +151,31 @@ export default abstract class Task extends EventEmitter implements ITask { // Voice tasks use holdResume(), but provide separate methods for interface compliance public async hold(): Promise { this.unsupportedMethodError('hold'); - - return Promise.reject(new Error('hold not supported for this channel type')); } public async resume(): Promise { this.unsupportedMethodError('resume'); - - return Promise.reject(new Error('resume not supported for this channel type')); } public async holdResume(): Promise { this.unsupportedMethodError('holdResume'); - - return Promise.reject(new Error('holdResume not supported for this channel type')); } /** - * Get UI controls for task actions. - * Computed from state machine state and context. - * - * @example - * ```typescript - * const visible = task.taskUiControls.hold.visible; - * const enabled = task.taskUiControls.hold.enabled; - * ``` + * Latest UI controls derived from state machine state and context. */ - public get taskUiControls(): TaskUIActions { - // Convert computed UI controls to TaskActionControl objects for backward compatibility - const controls = this.computeUIControls(); - const result: any = {}; - - Object.keys(controls).forEach((key) => { - const control = controls[key as keyof TaskUIControls]; - result[key] = new TaskButtonControl(control.visible, control.enabled); - }); + public get uiControls(): TaskUIControls { + return this.currentUiControls; + } + + protected updateUiControls(forceEmit = false): void { + const nextControls = this.computeUIControls(); + const shouldEmit = forceEmit || haveUIControlsChanged(this.currentUiControls, nextControls); + this.currentUiControls = nextControls; - return result as TaskUIActions; + if (shouldEmit) { + this.emit(TASK_EVENTS.TASK_UI_CONTROLS_UPDATED, this.currentUiControls); + } } /** @@ -248,24 +200,32 @@ export default abstract class Task extends EventEmitter implements ITask { onCleanupResources: () => {}, }; - const customActions = createActionsWithCallbacks(callbacks); - const machine = createTaskStateMachineWithActions(customActions); - - this.stateMachineService = interpret(machine) - .onTransition((state) => { - LoggerProxy.log( - `State machine transition: ${state.context.previousState || 'N/A'} -> ${state.value}`, - { - module: CC_FILE, - method: 'onTransition', - } - ); - this.state = state; - - // Update UI controls based on current state - this.computeUIControls(); - }) - .start(); + // Create custom actions with callbacks for event emission + const eventActions = createActionsWithCallbacks(callbacks); + + const machine: TaskStateMachine = createTaskStateMachineWithActions( + this.uiControlConfig, + eventActions + ); + + this.stateMachineService = createActor(machine); + + this.stateMachineService.subscribe((snapshot) => { + const previousState = this.lastState; + const currentState = snapshot.value as TaskState; + LoggerProxy.log(`State machine transition: ${previousState || 'N/A'} -> ${currentState}`, { + module: CC_FILE, + method: 'onTransition', + }); + this.lastState = currentState; + this.state = snapshot; + + // Update UI controls based on current state + this.updateUiControls(); + }); + + this.stateMachineService.start(); + this.updateUiControls(true); } /** @@ -281,36 +241,25 @@ export default abstract class Task extends EventEmitter implements ITask { * Get the current state machine state */ protected getCurrentState(): TaskState | undefined { - return this.stateMachineService?.state?.value as TaskState; + return this.stateMachineService?.getSnapshot()?.value as TaskState; } /** * Compute UI controls based on current state machine state. - * This method should be overridden by child classes (Voice, Digital) - * to provide channel-specific UI control logic. * * @returns UI control states for all task actions */ protected computeUIControls(): TaskUIControls { - // Default implementation - all controls hidden - // Child classes should override this method - return { - accept: {visible: false, enabled: false}, - decline: {visible: false, enabled: false}, - hold: {visible: false, enabled: false}, - mute: {visible: false, enabled: false}, - end: {visible: false, enabled: false}, - transfer: {visible: false, enabled: false}, - consult: {visible: false, enabled: false}, - consultTransfer: {visible: false, enabled: false}, - endConsult: {visible: false, enabled: false}, - recording: {visible: false, enabled: false}, - conference: {visible: false, enabled: false}, - wrapup: {visible: false, enabled: false}, - exitConference: {visible: false, enabled: false}, - transferConference: {visible: false, enabled: false}, - mergeToConference: {visible: false, enabled: false}, - }; + const snapshot = this.stateMachineService?.getSnapshot?.(); + + if (!snapshot) { + return getDefaultUIControls(); + } + + const currentState = snapshot.value as TaskState; + const context = snapshot.context as TaskContext; + + return computeUIControls(currentState, context, this.data); } /** @@ -361,7 +310,7 @@ export default abstract class Task extends EventEmitter implements ITask { public updateTaskData(updatedData: TaskData, shouldOverwrite = false): ITask { this.data = shouldOverwrite ? updatedData : this.reconcileData(this.data, updatedData); - this.computeUIControls(); + this.updateUiControls(); return this; } diff --git a/packages/@webex/contact-center/src/services/task/TaskManager.ts b/packages/@webex/contact-center/src/services/task/TaskManager.ts index 14bbe0c06c0..ff7c3e99a36 100644 --- a/packages/@webex/contact-center/src/services/task/TaskManager.ts +++ b/packages/@webex/contact-center/src/services/task/TaskManager.ts @@ -12,12 +12,6 @@ import LoggerProxy from '../../logger-proxy'; import MetricsManager from '../../metrics/MetricsManager'; import {METRIC_EVENT_NAMES} from '../../metrics/constants'; import TaskFactory from './TaskFactory'; -import { - checkParticipantNotInInteraction, - getIsConferenceInProgress, - isParticipantInMainInteraction, - isPrimary, -} from './TaskUtils'; import WebRTC from './voice/WebRTC'; import {TaskEvent, type TaskEventPayload} from './state-machine'; /** @internal */ @@ -156,9 +150,6 @@ export default class TaskManager extends EventEmitter { return {type: TaskEvent.CTQ_CANCEL}; case CC_EVENTS.AGENT_VTEAM_TRANSFERRED: - case CC_EVENTS.AGENT_CONFERENCE_TRANSFERRED: - return {type: TaskEvent.TRANSFER}; - case CC_EVENTS.AGENT_WRAPUP: case CC_EVENTS.AGENT_CONTACT_UNASSIGNED: return {type: TaskEvent.WRAPUP_START}; @@ -178,33 +169,6 @@ export default class TaskManager extends EventEmitter { case CC_EVENTS.CONTACT_RECORDING_RESUMED: return {type: TaskEvent.RESUME_RECORDING}; - case CC_EVENTS.AGENT_CONSULT_CONFERENCING: - return {type: TaskEvent.START_CONFERENCE}; - - case CC_EVENTS.AGENT_CONSULT_CONFERENCED: - return {type: TaskEvent.CONFERENCE_START, participants: []}; - - case CC_EVENTS.AGENT_CONSULT_CONFERENCE_ENDED: - return {type: TaskEvent.CONFERENCE_END}; - - case CC_EVENTS.PARTICIPANT_JOINED_CONFERENCE: - return { - type: TaskEvent.PARTICIPANT_JOIN, - participant: { - id: payload.data?.participantId || '', - type: 'AGENT', - joinedAt: new Date(), - isInitiator: false, - canBeRemoved: true, - }, - }; - - case CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE: - return { - type: TaskEvent.PARTICIPANT_LEAVE, - participantId: payload.data?.participantId || '', - }; - default: // Not all events need state machine mapping return null; @@ -444,94 +408,6 @@ export default class TaskManager extends EventEmitter { this.updateTaskData(task, payload.data); task.emit(TASK_EVENTS.TASK_RECORDING_RESUME_FAILED, task); break; - case CC_EVENTS.AGENT_CONSULT_CONFERENCING: - // Conference is being established - update task state and emit establishing event - this.updateTaskData(task, payload.data); - task.emit(TASK_EVENTS.TASK_CONFERENCE_ESTABLISHING, task); - break; - case CC_EVENTS.AGENT_CONSULT_CONFERENCED: - // Conference started successfully - update task state and emit event - this.updateTaskData(task, payload.data); - task.emit(TASK_EVENTS.TASK_CONFERENCE_STARTED, task); - break; - case CC_EVENTS.AGENT_CONSULT_CONFERENCE_FAILED: - // Conference failed - update task state and emit failure event - this.updateTaskData(task, payload.data); - task.emit(TASK_EVENTS.TASK_CONFERENCE_FAILED, task); - break; - case CC_EVENTS.AGENT_CONSULT_CONFERENCE_ENDED: - // Conference ended - update task state and emit event - this.updateTaskData(task, payload.data); - if ( - !task || - isPrimary(task, this.agentId) || - isParticipantInMainInteraction(task, this.agentId) - ) { - LoggerProxy.log('Primary or main interaction participant leaving conference'); - } else { - this.removeTaskFromCollection(task); - } - task?.emit(TASK_EVENTS.TASK_CONFERENCE_ENDED, task); - break; - case CC_EVENTS.PARTICIPANT_JOINED_CONFERENCE: { - // Participant joined conference - update task state with participant information and emit event - // Pre-calculate isConferenceInProgress with updated data to avoid double update - const simulatedTaskForJoin = { - ...task, - data: {...task.data, ...payload.data}, - }; - this.updateTaskData(task, { - ...payload.data, - isConferenceInProgress: getIsConferenceInProgress(simulatedTaskForJoin), - }); - task.emit(TASK_EVENTS.TASK_PARTICIPANT_JOINED, task); - break; - } - case CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE: { - // Conference ended - update task state and emit event - // Pre-calculate isConferenceInProgress with updated data to avoid double update - const simulatedTaskForLeft = { - ...task, - data: {...task.data, ...payload.data}, - }; - this.updateTaskData(task, { - ...payload.data, - isConferenceInProgress: getIsConferenceInProgress(simulatedTaskForLeft), - }); - if (checkParticipantNotInInteraction(task, this.agentId)) { - if ( - isParticipantInMainInteraction(task, this.agentId) || - isPrimary(task, this.agentId) - ) { - LoggerProxy.log('Primary or main interaction participant leaving conference'); - } else { - this.removeTaskFromCollection(task); - } - } - task.emit(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task); - break; - } - case CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE_FAILED: - // Conference exit failed - update task state and emit failure event - this.updateTaskData(task, payload.data); - task.emit(TASK_EVENTS.TASK_PARTICIPANT_LEFT_FAILED, task); - break; - case CC_EVENTS.AGENT_CONSULT_CONFERENCE_END_FAILED: - // Conference end failed - update task state with error details and emit failure event - this.updateTaskData(task, payload.data); - task.emit(TASK_EVENTS.TASK_CONFERENCE_END_FAILED, task); - break; - case CC_EVENTS.AGENT_CONFERENCE_TRANSFERRED: - // Conference was transferred - update task state and emit transfer success event - // Note: Backend should provide hasLeft and wrapUpRequired status - this.updateTaskData(task, payload.data); - task.emit(TASK_EVENTS.TASK_CONFERENCE_TRANSFERRED, task); - break; - case CC_EVENTS.AGENT_CONFERENCE_TRANSFER_FAILED: - // Conference transfer failed - update task state with error details and emit failure event - this.updateTaskData(task, payload.data); - task.emit(TASK_EVENTS.TASK_CONFERENCE_TRANSFER_FAILED, task); - break; case CC_EVENTS.CONSULTED_PARTICIPANT_MOVING: // Participant is being moved/transferred - update task state with movement info this.updateTaskData(task, payload.data); diff --git a/packages/@webex/contact-center/src/services/task/digital/Digital.ts b/packages/@webex/contact-center/src/services/task/digital/Digital.ts index ef8234ddbae..a765724eb5c 100644 --- a/packages/@webex/contact-center/src/services/task/digital/Digital.ts +++ b/packages/@webex/contact-center/src/services/task/digital/Digital.ts @@ -1,135 +1,23 @@ import {CC_FILE, METHODS} from '../../../constants'; import {getErrorDetails} from '../../core/Utils'; +import routingContact from '../contact'; import {IDigital, TaskResponse, TaskData} from '../types'; import Task from '../Task'; import LoggerProxy from '../../../logger-proxy'; import MetricsManager from '../../../metrics/MetricsManager'; import {METRIC_EVENT_NAMES} from '../../../metrics/constants'; -import {TaskState} from '../state-machine'; export default class Digital extends Task implements IDigital { - /** - * Compute UI controls based on state machine state for digital channels. - * This method determines which buttons should be visible and enabled - * based on the current task state. - * - * @returns UI control states for all task actions - */ - protected computeUIControls(): import('../Task').TaskUIControls { - const state = this.stateMachineService?.state; - - if (!state) { - // Fallback if state machine not initialized - return super.computeUIControls(); - } - - // Determine current state - const isOffered = state.matches(TaskState.OFFERED); - const isConnected = state.matches(TaskState.CONNECTED); - const isWrappingUp = state.matches(TaskState.WRAPPING_UP); - const isTerminated = this.data.interaction?.isTerminated ?? false; - - // For digital channels, determine if task needs wrapup - const needsWrapup = isTerminated || isWrappingUp; - - return { - // Accept button: visible when task is offered - accept: { - visible: isOffered, - enabled: isOffered, - }, - - // Decline: not used in digital channels - decline: { - visible: false, - enabled: false, - }, - - // Hold: not used in digital channels - hold: { - visible: false, - enabled: false, - }, - - // Mute: not used in digital channels - mute: { - visible: false, - enabled: false, - }, - - // End button: visible when connected, not when wrapping up - end: { - visible: isConnected && !isWrappingUp, - enabled: isConnected && !isWrappingUp, - }, - - // Transfer button: visible when connected, not when wrapping up - transfer: { - visible: isConnected && !isWrappingUp, - enabled: isConnected && !isWrappingUp, - }, - - // Consult: not used in digital channels - consult: { - visible: false, - enabled: false, - }, - - // Consult transfer: not used in digital channels - consultTransfer: { - visible: false, - enabled: false, - }, - - // End consult: not used in digital channels - endConsult: { - visible: false, - enabled: false, - }, - - // Recording: not used in digital channels - recording: { - visible: false, - enabled: false, - }, - - // Conference: not used in digital channels - conference: { - visible: false, - enabled: false, - }, - - // Wrapup button: visible when task is terminated or in wrapup state - wrapup: { - visible: needsWrapup, - enabled: needsWrapup, - }, - - // Exit conference: not used in digital channels - exitConference: { - visible: false, - enabled: false, - }, - - // Transfer conference: not used in digital channels - transferConference: { - visible: false, - enabled: false, - }, - - // Merge to conference: not used in digital channels - mergeToConference: { - visible: false, - enabled: false, - }, - }; + constructor(contact: ReturnType, data: TaskData) { + super(contact, data, { + channelType: 'digital', + isEndCallEnabled: true, + isEndConsultEnabled: false, + }); } /** - * Updates the task data with new information - * @param newData - Updated task data to apply - * @param shouldOverwrite - Whether to completely replace existing data - * @returns Updated Digital task instance + * Compute UI controls based on state machine state for digital channels. */ public updateTaskData(newData: TaskData, shouldOverwrite = false): IDigital { super.updateTaskData(newData, shouldOverwrite); diff --git a/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts b/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts index 370b2482369..ef5c9c10087 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts @@ -5,275 +5,251 @@ * It orchestrates state transitions, guards, and actions for task lifecycle management. */ -import {createMachine, StateMachine} from 'xstate'; -import {TaskContext, TaskState, TaskEvent, TaskEventPayload} from './types'; +import {createMachine} from 'xstate'; +import {TaskState, TaskEvent, UIControlConfig} from './types'; import {actions, createInitialContext} from './actions'; /** - * Task State Machine Configuration + * Get task state machine configuration with UI control config * Defines all states, transitions, guards, and actions for task management + * + * @param uiControlConfig - UI control configuration + * @returns State machine configuration object */ -export const taskStateMachineConfig = { - id: 'taskStateMachine', - initial: TaskState.IDLE, - context: createInitialContext(), - states: { - [TaskState.IDLE]: { - on: { - [TaskEvent.OFFER]: { - target: TaskState.OFFERED, - actions: ['initializeTask', 'updateState'], - }, - [TaskEvent.OFFER_CONSULT]: { - target: TaskState.OFFERED_CONSULT, - actions: ['initializeTask', 'updateState'], +export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { + return { + id: 'taskStateMachine', + initial: TaskState.IDLE, + context: createInitialContext(uiControlConfig, TaskState.IDLE), + states: { + [TaskState.IDLE]: { + on: { + [TaskEvent.OFFER]: { + target: TaskState.OFFERED, + actions: ['initializeTask'], + }, + [TaskEvent.OFFER_CONSULT]: { + target: TaskState.OFFERED_CONSULT, + actions: ['initializeTask'], + }, }, }, - }, - [TaskState.OFFERED]: { - on: { - [TaskEvent.ACCEPT]: { - target: TaskState.CONNECTED, - actions: ['updateState'], - }, - [TaskEvent.ASSIGN]: { - target: TaskState.CONNECTED, - actions: ['updateTaskData', 'updateState'], - }, - [TaskEvent.RONA]: { - target: TaskState.TERMINATED, - actions: ['markEnded', 'updateState'], - }, - [TaskEvent.END]: { - target: TaskState.TERMINATED, - actions: ['markEnded', 'updateState'], + [TaskState.OFFERED]: { + on: { + [TaskEvent.ACCEPT]: { + target: TaskState.CONNECTED, + }, + [TaskEvent.ASSIGN]: { + target: TaskState.CONNECTED, + actions: ['updateTaskData'], + }, + [TaskEvent.RONA]: { + target: TaskState.TERMINATED, + actions: ['markEnded'], + }, + [TaskEvent.END]: { + target: TaskState.TERMINATED, + actions: ['markEnded'], + }, }, }, - }, - [TaskState.OFFERED_CONSULT]: { - on: { - [TaskEvent.ACCEPT]: { - target: TaskState.CONSULTING, - actions: ['updateState'], - }, - [TaskEvent.RONA]: { - target: TaskState.TERMINATED, - actions: ['markEnded', 'updateState'], - }, - [TaskEvent.END]: { - target: TaskState.TERMINATED, - actions: ['markEnded', 'updateState'], + [TaskState.OFFERED_CONSULT]: { + on: { + [TaskEvent.ACCEPT]: { + target: TaskState.CONSULTING, + }, + [TaskEvent.RONA]: { + target: TaskState.TERMINATED, + actions: ['markEnded'], + }, + [TaskEvent.END]: { + target: TaskState.TERMINATED, + actions: ['markEnded'], + }, }, }, - }, - [TaskState.CONNECTED]: { - on: { - [TaskEvent.HOLD]: { - target: TaskState.HOLD_INITIATING, - actions: ['updateState'], - }, - [TaskEvent.CONSULT]: { - target: TaskState.CONSULT_INITIATING, - actions: ['setConsultInitiator', 'setConsultDestination', 'updateState'], - }, - [TaskEvent.CONSULT_CREATED]: { - target: TaskState.CONSULTING, - actions: ['updateTaskData', 'setConsultInitiator', 'updateState'], - }, - [TaskEvent.TRANSFER]: { - target: TaskState.WRAPPING_UP, - actions: ['updateState'], - }, - [TaskEvent.END]: { - target: TaskState.WRAPPING_UP, - actions: ['markEnded', 'updateState'], - }, - [TaskEvent.CONTACT_ENDED]: { - target: TaskState.WRAPPING_UP, - actions: ['markEnded', 'updateState'], - }, - [TaskEvent.PAUSE_RECORDING]: { - actions: ['setRecordingState'], - }, - [TaskEvent.RESUME_RECORDING]: { - actions: ['setRecordingState'], + [TaskState.CONNECTED]: { + on: { + [TaskEvent.HOLD]: { + target: TaskState.HOLD_INITIATING, + }, + [TaskEvent.CONSULT]: { + target: TaskState.CONSULT_INITIATING, + actions: ['setConsultInitiator', 'setConsultDestination'], + }, + [TaskEvent.CONSULT_CREATED]: { + target: TaskState.CONSULTING, + actions: ['updateTaskData', 'setConsultInitiator'], + }, + [TaskEvent.TRANSFER]: { + target: TaskState.WRAPPING_UP, + }, + [TaskEvent.END]: { + target: TaskState.WRAPPING_UP, + actions: ['markEnded'], + }, + [TaskEvent.CONTACT_ENDED]: { + target: TaskState.WRAPPING_UP, + actions: ['markEnded'], + }, + [TaskEvent.PAUSE_RECORDING]: { + actions: ['setRecordingState'], + }, + [TaskEvent.RESUME_RECORDING]: { + actions: ['setRecordingState'], + }, }, }, - }, - [TaskState.HOLD_INITIATING]: { - on: { - [TaskEvent.HOLD_SUCCESS]: { - target: TaskState.HELD, - actions: ['setHoldState', 'updateState'], - }, - [TaskEvent.HOLD_FAILED]: { - target: TaskState.CONNECTED, - actions: ['updateState'], + [TaskState.HOLD_INITIATING]: { + on: { + [TaskEvent.HOLD_SUCCESS]: { + target: TaskState.HELD, + actions: ['setHoldState'], + }, + [TaskEvent.HOLD_FAILED]: { + target: TaskState.CONNECTED, + }, }, }, - }, - [TaskState.HELD]: { - on: { - [TaskEvent.UNHOLD]: { - target: TaskState.RESUME_INITIATING, - actions: ['updateState'], - }, - [TaskEvent.CONSULT]: { - target: TaskState.CONSULT_INITIATING, - actions: ['setConsultInitiator', 'setConsultDestination', 'updateState'], - }, - [TaskEvent.TRANSFER]: { - target: TaskState.WRAPPING_UP, - actions: ['updateState'], - }, - [TaskEvent.END]: { - target: TaskState.WRAPPING_UP, - actions: ['markEnded', 'updateState'], + [TaskState.HELD]: { + on: { + [TaskEvent.UNHOLD]: { + target: TaskState.RESUME_INITIATING, + }, + [TaskEvent.CONSULT]: { + target: TaskState.CONSULT_INITIATING, + actions: ['setConsultInitiator', 'setConsultDestination'], + }, + [TaskEvent.TRANSFER]: { + target: TaskState.WRAPPING_UP, + }, + [TaskEvent.END]: { + target: TaskState.WRAPPING_UP, + actions: ['markEnded'], + }, }, }, - }, - [TaskState.RESUME_INITIATING]: { - on: { - [TaskEvent.UNHOLD_SUCCESS]: { - target: TaskState.CONNECTED, - actions: ['setHoldState', 'updateState'], - }, - [TaskEvent.UNHOLD_FAILED]: { - target: TaskState.HELD, - actions: ['updateState'], + [TaskState.RESUME_INITIATING]: { + on: { + [TaskEvent.UNHOLD_SUCCESS]: { + target: TaskState.CONNECTED, + actions: ['setHoldState'], + }, + [TaskEvent.UNHOLD_FAILED]: { + target: TaskState.HELD, + }, }, }, - }, - [TaskState.CONSULT_INITIATING]: { - on: { - [TaskEvent.CONSULT_SUCCESS]: { - target: TaskState.CONSULTING, - actions: ['updateState'], - }, - [TaskEvent.CONSULT_FAILED]: { - target: TaskState.CONNECTED, - actions: ['updateState'], + [TaskState.CONSULT_INITIATING]: { + on: { + [TaskEvent.CONSULT_SUCCESS]: { + target: TaskState.CONSULTING, + }, + [TaskEvent.CONSULT_FAILED]: { + target: TaskState.CONNECTED, + }, }, }, - }, - [TaskState.CONSULTING]: { - on: { - [TaskEvent.CONSULTING_ACTIVE]: { - actions: ['setConsultAgentJoined'], - }, - [TaskEvent.START_CONFERENCE]: { - target: TaskState.CONFERENCING, - actions: ['initializeConference', 'updateState'], - }, - [TaskEvent.MERGE_TO_CONFERENCE]: { - target: TaskState.CONFERENCING, - actions: ['initializeConference', 'updateState'], - }, - [TaskEvent.CONFERENCE_START]: { - target: TaskState.CONFERENCING, - actions: ['setConferencing', 'updateState'], - }, - [TaskEvent.CONSULT_END]: { - target: TaskState.CONNECTED, - actions: ['clearConsultState', 'updateState'], - }, - [TaskEvent.CONSULT_TRANSFER]: { - target: TaskState.WRAPPING_UP, - actions: ['clearConsultState', 'updateState'], - }, - [TaskEvent.TRANSFER]: { - target: TaskState.WRAPPING_UP, - actions: ['updateState'], - }, - [TaskEvent.END]: { - target: TaskState.WRAPPING_UP, - actions: ['markEnded', 'clearConsultState', 'updateState'], - }, - [TaskEvent.CONTACT_ENDED]: { - target: TaskState.WRAPPING_UP, - actions: ['markEnded', 'clearConsultState', 'updateState'], + [TaskState.CONSULTING]: { + on: { + [TaskEvent.CONSULTING_ACTIVE]: { + actions: ['setConsultAgentJoined'], + }, + [TaskEvent.START_CONFERENCE]: { + target: TaskState.CONFERENCING, + }, + [TaskEvent.MERGE_TO_CONFERENCE]: { + target: TaskState.CONFERENCING, + }, + [TaskEvent.CONFERENCE_START]: { + target: TaskState.CONFERENCING, + }, + [TaskEvent.CONSULT_END]: { + target: TaskState.CONNECTED, + actions: ['clearConsultState'], + }, + [TaskEvent.CONSULT_TRANSFER]: { + target: TaskState.WRAPPING_UP, + actions: ['clearConsultState'], + }, + [TaskEvent.TRANSFER]: { + target: TaskState.WRAPPING_UP, + }, + [TaskEvent.END]: { + target: TaskState.WRAPPING_UP, + actions: ['markEnded', 'clearConsultState'], + }, + [TaskEvent.CONTACT_ENDED]: { + target: TaskState.WRAPPING_UP, + actions: ['markEnded', 'clearConsultState'], + }, }, }, - }, - [TaskState.CONFERENCING]: { - on: { - [TaskEvent.PARTICIPANT_JOIN]: { - actions: ['addParticipant'], - }, - [TaskEvent.PARTICIPANT_LEAVE]: { - actions: ['removeParticipant'], - }, - [TaskEvent.EXIT_CONFERENCE]: { - target: TaskState.WRAPPING_UP, - actions: ['clearConferencing', 'markEnded', 'updateState'], - }, - [TaskEvent.TRANSFER_CONFERENCE]: { - target: TaskState.WRAPPING_UP, - actions: ['clearConferencing', 'updateState'], - }, - [TaskEvent.CONFERENCE_END]: { - target: TaskState.WRAPPING_UP, - actions: ['clearConferencing', 'markEnded', 'updateState'], - }, - [TaskEvent.END]: { - target: TaskState.WRAPPING_UP, - actions: ['markEnded', 'clearConferencing', 'updateState'], - }, - [TaskEvent.CONTACT_ENDED]: { - target: TaskState.WRAPPING_UP, - actions: ['markEnded', 'clearConferencing', 'updateState'], + [TaskState.CONFERENCING]: { + on: { + [TaskEvent.EXIT_CONFERENCE]: { + target: TaskState.WRAPPING_UP, + actions: ['markEnded'], + }, + [TaskEvent.TRANSFER_CONFERENCE]: { + target: TaskState.WRAPPING_UP, + }, + [TaskEvent.CONFERENCE_END]: { + target: TaskState.WRAPPING_UP, + actions: ['markEnded'], + }, + [TaskEvent.END]: { + target: TaskState.WRAPPING_UP, + actions: ['markEnded'], + }, + [TaskEvent.CONTACT_ENDED]: { + target: TaskState.WRAPPING_UP, + actions: ['markEnded'], + }, }, }, - }, - [TaskState.WRAPPING_UP]: { - on: { - [TaskEvent.WRAPUP]: { - target: TaskState.COMPLETED, - actions: ['updateState'], - }, - [TaskEvent.AUTO_WRAPUP]: { - target: TaskState.COMPLETED, - actions: ['updateState'], + [TaskState.WRAPPING_UP]: { + on: { + [TaskEvent.WRAPUP]: { + target: TaskState.COMPLETED, + }, + [TaskEvent.AUTO_WRAPUP]: { + target: TaskState.COMPLETED, + }, }, }, - }, - [TaskState.COMPLETED]: { - type: 'final' as const, - entry: ['cleanupResources'], - }, + [TaskState.COMPLETED]: { + type: 'final' as const, + entry: ['cleanupResources'], + }, - [TaskState.TERMINATED]: { - type: 'final' as const, - entry: ['cleanupResources'], + [TaskState.TERMINATED]: { + type: 'final' as const, + entry: ['cleanupResources'], + }, }, - }, -}; + }; +} /** * Create a task state machine instance + * + * @param uiControlConfig - UI control configuration * @returns StateMachine instance for task management */ -export function createTaskStateMachine(): StateMachine< - TaskContext, - any, - TaskEventPayload, - any, - any, - any, - any -> { - return createMachine(taskStateMachineConfig, { +export function createTaskStateMachine(uiControlConfig: UIControlConfig) { + return createMachine(getTaskStateMachineConfig(uiControlConfig), { actions, }); } @@ -281,16 +257,21 @@ export function createTaskStateMachine(): StateMachine< /** * Create a task state machine with custom actions * This allows the Task/Voice class to inject their own event emission and side effects + * + * @param uiControlConfig - UI control configuration * @param customActions - Custom action implementations * @returns StateMachine instance with custom actions */ export function createTaskStateMachineWithActions( + uiControlConfig: UIControlConfig, customActions: Record -): StateMachine { - return createMachine(taskStateMachineConfig, { +) { + return createMachine(getTaskStateMachineConfig(uiControlConfig), { actions: { ...actions, ...customActions, }, }); } + +export type TaskStateMachine = ReturnType; diff --git a/packages/@webex/contact-center/src/services/task/state-machine/actions.ts b/packages/@webex/contact-center/src/services/task/state-machine/actions.ts index a765bd6e1cd..769ea1ac3fc 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/actions.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/actions.ts @@ -12,25 +12,56 @@ */ import {assign} from 'xstate'; -import {TaskContext, TaskEventPayload, isEventOfType, TaskEvent} from './types'; +import { + TaskContext, + TaskEventPayload, + isEventOfType, + TaskEvent, + UIControlConfig, + TaskState, +} from './types'; +import {TaskData} from '../types'; +import {computeUIControls, getDefaultUIControls} from './uiControlsComputer'; /** * Create initial context for a new task + * + * @param uiControlConfig - UI control configuration + * @param initialState - Initial state for computing UI controls + * @returns Initial context with UI controls */ -export function createInitialContext(): TaskContext { - return { +export function createInitialContext( + uiControlConfig: UIControlConfig, + initialState: TaskState = TaskState.IDLE +): TaskContext { + const baseContext: TaskContext = { taskData: null, - previousState: null, consultInitiator: false, consultDestination: null, consultDestinationAgentJoined: false, - conferenceInitiatorId: null, - conferenceParticipants: [], - maxConferenceParticipants: 10, - participants: [], // DEPRECATED: Use conferenceParticipants instead recordingActive: false, recordingPaused: false, + uiControlConfig, + uiControls: getDefaultUIControls(), }; + + // Compute initial UI controls + baseContext.uiControls = computeUIControls(initialState, baseContext); + + return baseContext; +} + +/** + * Helper to update UI controls after context changes + * This should be called after any action that modifies context + * + * @param currentState - Current state machine state + * @returns Assign action that updates UI controls + */ +export function updateUIControls(currentState: TaskState) { + return assign((context: TaskContext) => ({ + uiControls: computeUIControls(currentState, context), + })); } /** @@ -41,10 +72,11 @@ export const actions = { /** * Initialize task with offer data */ - initializeTask: assign((context, event) => { + initializeTask: assign((context: TaskContext, event: TaskEventPayload) => { if (isEventOfType(event, TaskEvent.OFFER) || isEventOfType(event, TaskEvent.OFFER_CONSULT)) { return { taskData: event.taskData, + ...deriveRecordingState(event.taskData), }; } @@ -54,15 +86,17 @@ export const actions = { /** * Update task data from ASSIGN event */ - updateTaskData: assign((context, event) => { + updateTaskData: assign((context: TaskContext, event: TaskEventPayload) => { if (isEventOfType(event, TaskEvent.ASSIGN)) { return { taskData: event.taskData, + ...deriveRecordingState(event.taskData), }; } if (isEventOfType(event, TaskEvent.CONSULT_CREATED)) { return { taskData: event.taskData, + ...deriveRecordingState(event.taskData), }; } @@ -72,14 +106,14 @@ export const actions = { /** * Set consult initiator flag */ - setConsultInitiator: assign({ + setConsultInitiator: assign({ consultInitiator: true, }), /** * Set consult destination details */ - setConsultDestination: assign((context, event) => { + setConsultDestination: assign((context: TaskContext, event: TaskEventPayload) => { if (isEventOfType(event, TaskEvent.CONSULT)) { return { consultDestination: event.destination, @@ -92,7 +126,7 @@ export const actions = { /** * Mark that consult destination agent has joined */ - setConsultAgentJoined: assign((context, event) => { + setConsultAgentJoined: assign((context: TaskContext, event: TaskEventPayload) => { if (isEventOfType(event, TaskEvent.CONSULTING_ACTIVE)) { return { consultDestinationAgentJoined: event.consultDestinationAgentJoined, @@ -103,62 +137,17 @@ export const actions = { }), /** - * Set conferencing state (legacy - kept for backward compatibility) + * Set recording state */ - setConferencing: assign((context, event) => { - if (isEventOfType(event, TaskEvent.CONFERENCE_START)) { - const participantIds = event.participants?.map((p) => p.id) || []; - + setRecordingState: assign((context: TaskContext, event: TaskEventPayload) => { + if (isEventOfType(event, TaskEvent.PAUSE_RECORDING)) { return { - conferenceParticipants: event.participants || [], - participants: participantIds, + recordingPaused: true, }; } - - return {}; - }), - - /** - * Initialize conference with participants from consult - */ - initializeConference: assign((context) => { - const agentId = context.taskData?.agentId; - const consultAgent = context.consultDestination; - - if (!agentId || !consultAgent) { - return {}; - } - - return { - conferenceInitiatorId: agentId, - conferenceParticipants: [ - { - id: agentId, - type: 'AGENT' as const, - joinedAt: new Date(), - isInitiator: true, - canBeRemoved: false, - }, - { - id: consultAgent, - type: 'AGENT' as const, - joinedAt: new Date(), - isInitiator: false, - canBeRemoved: true, - }, - ], - consultDestination: null, - consultDestinationAgentJoined: false, - }; - }), - - /** - * Add a participant to conference - */ - addParticipant: assign((context, event) => { - if (isEventOfType(event, TaskEvent.PARTICIPANT_JOIN)) { + if (isEventOfType(event, TaskEvent.RESUME_RECORDING)) { return { - conferenceParticipants: [...context.conferenceParticipants, event.participant], + recordingPaused: false, }; } @@ -166,36 +155,45 @@ export const actions = { }), /** - * Remove a participant from conference + * Clear consult state */ - removeParticipant: assign((context, event) => { - if (isEventOfType(event, TaskEvent.PARTICIPANT_LEAVE)) { - return { - conferenceParticipants: context.conferenceParticipants.filter( - (p) => p.id !== event.participantId - ), - }; - } - - return {}; + clearConsultState: assign({ + consultDestination: null, + consultDestinationAgentJoined: false, }), /** - * Update conference participants (handles both JOIN and LEAVE) + * Track hold state updates (currently no-op placeholder) */ - updateParticipants: assign((context, event) => { - if (isEventOfType(event, TaskEvent.PARTICIPANT_JOIN)) { - return { - conferenceParticipants: [...context.conferenceParticipants, event.participant], - participants: [...context.participants, event.participant.id], + setHoldState: assign((context: TaskContext, event: TaskEventPayload) => { + if ( + isEventOfType(event, TaskEvent.HOLD_SUCCESS) || + isEventOfType(event, TaskEvent.UNHOLD_SUCCESS) + ) { + const mediaResourceId = event.mediaResourceId; + const interaction = context.taskData?.interaction; + const mediaEntry = interaction?.media?.[mediaResourceId]; + + if (!interaction || !mediaEntry) { + return {}; + } + + const updatedMedia = { + ...interaction.media, + [mediaResourceId]: { + ...mediaEntry, + isHold: isEventOfType(event, TaskEvent.HOLD_SUCCESS), + }, }; - } - if (isEventOfType(event, TaskEvent.PARTICIPANT_LEAVE)) { + return { - conferenceParticipants: context.conferenceParticipants.filter( - (p) => p.id !== event.participantId - ), - participants: context.participants.filter((id) => id !== event.participantId), + taskData: { + ...(context.taskData as TaskData), + interaction: { + ...interaction, + media: updatedMedia, + }, + }, }; } @@ -203,39 +201,73 @@ export const actions = { }), /** - * Clear conferencing state + * Mark task as ended (currently no-op placeholder) */ - clearConferencing: assign({ - conferenceInitiatorId: null, - conferenceParticipants: [], - participants: [], - }), + markEnded: assign(() => ({ + recordingActive: false, + recordingPaused: false, + })), /** - * Set recording state + * Cleanup resources on task completion (placeholder) */ - setRecordingState: assign((context, event) => { - if (isEventOfType(event, TaskEvent.PAUSE_RECORDING)) { - return { - recordingPaused: true, - }; + cleanupResources: () => { + return undefined; + }, +}; + +type RecordingStateUpdate = Partial>; + +const parseBooleanFlag = (value?: string | boolean | null): boolean | undefined => { + if (typeof value === 'boolean') { + return value; + } + + if (typeof value === 'string') { + const normalized = value.toLowerCase(); + + if (normalized === 'true') { + return true; } - if (isEventOfType(event, TaskEvent.RESUME_RECORDING)) { - return { - recordingPaused: false, - }; + if (normalized === 'false') { + return false; } + } + + return undefined; +}; + +const deriveRecordingState = (taskData?: TaskData | null): RecordingStateUpdate => { + const callProcessingDetails = taskData?.interaction?.callProcessingDetails; + if (!callProcessingDetails) { return {}; - }), + } - /** - * Clear consult state - */ - clearConsultState: assign({ - consultDestination: null, - consultDestinationAgentJoined: false, - }), + const update: RecordingStateUpdate = {}; + const recordInProgress = parseBooleanFlag( + callProcessingDetails.recordInProgress ?? callProcessingDetails.recordingStarted + ); + const isPaused = parseBooleanFlag(callProcessingDetails.isPaused); + + if (typeof recordInProgress !== 'undefined') { + update.recordingActive = recordInProgress; + if (!recordInProgress) { + update.recordingPaused = false; + } else if (typeof isPaused === 'undefined') { + update.recordingPaused = false; + } + } + + if (typeof isPaused !== 'undefined') { + update.recordingPaused = isPaused; + + if (isPaused) { + update.recordingActive = true; + } + } + + return update; }; /** @@ -251,8 +283,6 @@ export interface ActionCallbacks { onTaskConsultCreated?: (taskData: any) => void; onTaskConsulting?: (taskData: any) => void; onTaskConsultEnd?: (taskData: any) => void; - onTaskConferenceStarted?: (taskData: any) => void; - onTaskConferenceEnded?: (taskData: any) => void; onTaskEnd?: (taskData: any) => void; onTaskWrappedup?: (taskData: any) => void; onCleanupResources?: () => void; @@ -286,12 +316,6 @@ export function createActionsWithCallbacks(callbacks: ActionCallbacks) { emitTaskConsultEnd: (context: TaskContext) => { callbacks.onTaskConsultEnd?.(context.taskData); }, - emitTaskConferenceStarted: (context: TaskContext) => { - callbacks.onTaskConferenceStarted?.(context.taskData); - }, - emitTaskConferenceEnded: (context: TaskContext) => { - callbacks.onTaskConferenceEnded?.(context.taskData); - }, emitTaskEnd: (context: TaskContext) => { callbacks.onTaskEnd?.(context.taskData); }, diff --git a/packages/@webex/contact-center/src/services/task/state-machine/index.ts b/packages/@webex/contact-center/src/services/task/state-machine/index.ts index e5c5f54e88e..61d07f6c5f0 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/index.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/index.ts @@ -6,14 +6,21 @@ // Main state machine export { - taskStateMachineConfig, + getTaskStateMachineConfig, createTaskStateMachine, createTaskStateMachineWithActions, } from './TaskStateMachine'; +export type {TaskStateMachine} from './TaskStateMachine'; // Types export {TaskState, TaskEvent, isEventOfType} from './types'; -export type {TaskContext, TaskEventPayload, TaskStateMachineConfig, UIControls} from './types'; +export type { + TaskContext, + TaskEventPayload, + TaskStateMachineConfig, + UIControls, + UIControlConfig, +} from './types'; // Guards export {guards} from './guards'; diff --git a/packages/@webex/contact-center/src/services/task/state-machine/types.ts b/packages/@webex/contact-center/src/services/task/state-machine/types.ts index 96efe3205eb..fa2d59c393b 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/types.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/types.ts @@ -5,7 +5,7 @@ * These types define states, events, context, and schemas for task lifecycle management. */ -import {TaskData} from '../types'; +import {TaskData, TaskUIControls} from '../types'; /** * All possible states in the task state machine @@ -124,6 +124,20 @@ export interface ConferenceParticipant { canBeRemoved: boolean; } +/** + * UI Control configuration for the task + */ +export interface UIControlConfig { + /** Whether end call button is enabled (config option) */ + isEndCallEnabled: boolean; + /** Whether end consult button is enabled (config option) */ + isEndConsultEnabled: boolean; + /** Channel type determines which controls are available */ + channelType: 'voice' | 'digital'; + /** Optional voice channel variant to toggle WebRTC-specific controls */ + voiceVariant?: 'pstn' | 'webrtc'; +} + /** * Context data maintained by the state machine * @@ -145,23 +159,20 @@ export interface TaskContext { // Task data taskData: TaskData | null; - // State tracking - previousState: TaskState | null; - // Consult tracking consultInitiator: boolean; consultDestination: string | null; consultDestinationAgentJoined: boolean; - // Conference tracking - conferenceInitiatorId: string | null; - conferenceParticipants: ConferenceParticipant[]; - maxConferenceParticipants: number; - participants: string[]; // DEPRECATED: Use conferenceParticipants instead - // Recording tracking recordingActive: boolean; recordingPaused: boolean; + + // UI Control configuration (set at task creation) + uiControlConfig: UIControlConfig; + + // Computed UI controls (derived from state + context + config) + uiControls: TaskUIControls; } /** @@ -216,30 +227,30 @@ export type TaskEventPayload = * Type guard to check event type */ export function isEventOfType( - event: TaskEventPayload, + event: TaskEventPayload | undefined, type: T ): event is Extract { - return event.type === type; + return Boolean(event && event.type === type); } /** * UI Control states derived from state machine */ export interface UIControls { - accept: {visible: boolean; enabled: boolean}; - decline: {visible: boolean; enabled: boolean}; - hold: {visible: boolean; enabled: boolean; label: 'Hold' | 'Resume'}; - transfer: {visible: boolean; enabled: boolean}; - consult: {visible: boolean; enabled: boolean}; - end: {visible: boolean; enabled: boolean}; - recording: {visible: boolean; enabled: boolean}; - mute: {visible: boolean; enabled: boolean}; - consultTransfer: {visible: boolean; enabled: boolean}; - endConsult: {visible: boolean; enabled: boolean}; - conference: {visible: boolean; enabled: boolean}; - exitConference: {visible: boolean; enabled: boolean}; - transferConference: {visible: boolean; enabled: boolean}; - wrapup: {visible: boolean; enabled: boolean}; + accept: {isVisible: boolean; isEnabled: boolean}; + decline: {isVisible: boolean; isEnabled: boolean}; + hold: {isVisible: boolean; isEnabled: boolean; label: 'Hold' | 'Resume'}; + transfer: {isVisible: boolean; isEnabled: boolean}; + consult: {isVisible: boolean; isEnabled: boolean}; + end: {isVisible: boolean; isEnabled: boolean}; + recording: {isVisible: boolean; isEnabled: boolean}; + mute: {isVisible: boolean; isEnabled: boolean}; + consultTransfer: {isVisible: boolean; isEnabled: boolean}; + endConsult: {isVisible: boolean; isEnabled: boolean}; + conference: {isVisible: boolean; isEnabled: boolean}; + exitConference: {isVisible: boolean; isEnabled: boolean}; + transferConference: {isVisible: boolean; isEnabled: boolean}; + wrapup: {isVisible: boolean; isEnabled: boolean}; } /** @@ -265,8 +276,6 @@ export enum TaskAction { EMIT_TASK_CONSULT_CREATED = 'emitTaskConsultCreated', EMIT_TASK_CONSULTING = 'emitTaskConsulting', EMIT_TASK_CONSULT_END = 'emitTaskConsultEnd', - EMIT_TASK_CONFERENCE_STARTED = 'emitTaskConferenceStarted', - EMIT_TASK_CONFERENCE_ENDED = 'emitTaskConferenceEnded', EMIT_TASK_END = 'emitTaskEnd', EMIT_TASK_WRAPPEDUP = 'emitTaskWrappedup', CLEANUP_RESOURCES = 'cleanupResources', @@ -276,8 +285,6 @@ export enum TaskAction { SET_CONSULT_INITIATOR = 'setConsultInitiator', SET_CONSULT_DESTINATION = 'setConsultDestination', SET_CONSULT_AGENT_JOINED = 'setConsultAgentJoined', - SET_CONFERENCING = 'setConferencing', - UPDATE_PARTICIPANTS = 'updateParticipants', SET_HOLD_STATE = 'setHoldState', SET_RECORDING_STATE = 'setRecordingState', UPDATE_TIMESTAMP = 'updateTimestamp', diff --git a/packages/@webex/contact-center/src/services/task/state-machine/uiControlsComputer.ts b/packages/@webex/contact-center/src/services/task/state-machine/uiControlsComputer.ts new file mode 100644 index 00000000000..8de99d89a32 --- /dev/null +++ b/packages/@webex/contact-center/src/services/task/state-machine/uiControlsComputer.ts @@ -0,0 +1,313 @@ +/** + * UI Controls Computer + * + * Centralized logic for computing UI control states based on: + * - State machine current state + * - State machine context + * - Configuration + */ + +import {TaskData, TaskUIControls} from '../types'; +import {TaskState, TaskContext, UIControlConfig} from './types'; + +/** + * Get default UI controls (all hidden/disabled) + */ +export function getDefaultUIControls(): TaskUIControls { + return { + accept: {isVisible: false, isEnabled: false}, + decline: {isVisible: false, isEnabled: false}, + hold: {isVisible: false, isEnabled: false}, + mute: {isVisible: false, isEnabled: false}, + end: {isVisible: false, isEnabled: false}, + transfer: {isVisible: false, isEnabled: false}, + consult: {isVisible: false, isEnabled: false}, + consultTransfer: {isVisible: false, isEnabled: false}, + endConsult: {isVisible: false, isEnabled: false}, + recording: {isVisible: false, isEnabled: false}, + conference: {isVisible: false, isEnabled: false}, + wrapup: {isVisible: false, isEnabled: false}, + exitConference: {isVisible: false, isEnabled: false}, + transferConference: {isVisible: false, isEnabled: false}, + mergeToConference: {isVisible: false, isEnabled: false}, + }; +} + +/** + * Compute UI controls for voice channel + */ +function computeVoiceUIControls( + currentState: TaskState, + context: TaskContext, + config: UIControlConfig, + fallbackTaskData?: TaskData +): TaskUIControls { + const isWebrtc = config.voiceVariant === 'webrtc'; + const isOffered = + currentState === TaskState.OFFERED || currentState === TaskState.OFFERED_CONSULT; + const isConnected = currentState === TaskState.CONNECTED; + const isHeld = currentState === TaskState.HELD; + const isConsulting = currentState === TaskState.CONSULTING; + const isConferencing = currentState === TaskState.CONFERENCING; + const isWrappingUp = currentState === TaskState.WRAPPING_UP; + const taskData = context.taskData ?? fallbackTaskData ?? null; + const isConsultedAgent = Boolean(taskData?.isConsulted); + const isTerminated = taskData?.interaction?.isTerminated ?? false; + const shouldShowAcceptDecline = isWebrtc + ? isOffered && !isTerminated && (!isConsulting || !isConsultedAgent) + : isOffered; + const muteVisible = isWebrtc + ? isConnected || (isConsulting && isConsultedAgent) + : isConnected || isHeld; + const muteEnabled = isWebrtc ? muteVisible && !isHeld && !isWrappingUp : !isWrappingUp; + + return { + // Accept button: visible when offered, always enabled + accept: { + isVisible: shouldShowAcceptDecline, + isEnabled: isWebrtc ? shouldShowAcceptDecline : true, + }, + + // Decline button: visible when offered, always enabled + decline: { + isVisible: shouldShowAcceptDecline, + isEnabled: isWebrtc ? shouldShowAcceptDecline : true, + }, + + // Hold button: visible when connected or held + // Enabled based on current state (hold when connected, resume when held) + hold: { + isVisible: isConnected || isHeld, + isEnabled: isConnected || isHeld, + }, + + // Mute button: visible when active call, disabled during wrapup + mute: { + isVisible: muteVisible, + isEnabled: muteEnabled, + }, + + // End button: conditional based on config, disabled when held or wrapping up + end: { + isVisible: config.isEndCallEnabled, + isEnabled: !isHeld && !isWrappingUp, + }, + + // Transfer button: visible in connected/held/consulting states + transfer: { + isVisible: isConnected || isHeld || isConsulting, + isEnabled: true, + }, + + // Consult button: visible when connected or held + // Enabled when in connected or held states (not consulting/conferencing) + consult: { + isVisible: isConnected || isHeld, + isEnabled: isConnected || isHeld, + }, + + // Consult transfer: visible during consulting + consultTransfer: { + isVisible: isConsulting, + isEnabled: true, + }, + + // End consult button: visible during consulting state + endConsult: { + isVisible: isConsulting, + isEnabled: config.isEndConsultEnabled, + }, + + // Recording controls: based on recording state + recording: { + isVisible: isConnected || isHeld, + isEnabled: !context.recordingPaused, + }, + + // Conference button: visible during consulting + // Enabled only if consulted agent has joined + conference: { + isVisible: isConsulting, + isEnabled: context.consultDestinationAgentJoined, + }, + + // Wrapup button: visible during wrapup state + wrapup: { + isVisible: isWrappingUp, + isEnabled: true, + }, + + // Exit conference button: visible during conference + exitConference: { + isVisible: isConferencing, + isEnabled: true, + }, + + // Transfer conference: visible during conference + transferConference: { + isVisible: isConferencing, + isEnabled: true, + }, + + // Merge to conference: visible during consulting (alias for conference) + mergeToConference: { + isVisible: isConsulting, + isEnabled: context.consultDestinationAgentJoined, + }, + }; +} + +/** + * Compute UI controls for digital channel + */ +function computeDigitalUIControls( + currentState: TaskState, + context: TaskContext, + fallbackTaskData?: TaskData +): TaskUIControls { + const isOffered = currentState === TaskState.OFFERED; + const isConnected = currentState === TaskState.CONNECTED; + const isWrappingUp = currentState === TaskState.WRAPPING_UP; + const taskData = context.taskData ?? fallbackTaskData ?? null; + const isTerminated = taskData?.interaction?.isTerminated ?? false; + + // For digital channels, determine if task needs wrapup + const needsWrapup = isTerminated || isWrappingUp; + + return { + // Accept button: visible when task is offered + accept: { + isVisible: isOffered, + isEnabled: isOffered, + }, + + // Decline: not used in digital channels + decline: { + isVisible: false, + isEnabled: false, + }, + + // Hold: not used in digital channels + hold: { + isVisible: false, + isEnabled: false, + }, + + // Mute: not used in digital channels + mute: { + isVisible: false, + isEnabled: false, + }, + + // End button: visible when connected, not when wrapping up + end: { + isVisible: isConnected && !isWrappingUp, + isEnabled: isConnected && !isWrappingUp, + }, + + // Transfer button: visible when connected, not when wrapping up + transfer: { + isVisible: isConnected && !isWrappingUp, + isEnabled: isConnected && !isWrappingUp, + }, + + // Consult: not used in digital channels + consult: { + isVisible: false, + isEnabled: false, + }, + + // Consult transfer: not used in digital channels + consultTransfer: { + isVisible: false, + isEnabled: false, + }, + + // End consult: not used in digital channels + endConsult: { + isVisible: false, + isEnabled: false, + }, + + // Recording: not used in digital channels + recording: { + isVisible: false, + isEnabled: false, + }, + + // Conference: not used in digital channels + conference: { + isVisible: false, + isEnabled: false, + }, + + // Wrapup button: visible when task is terminated or in wrapup state + wrapup: { + isVisible: needsWrapup, + isEnabled: needsWrapup, + }, + + // Exit conference: not used in digital channels + exitConference: { + isVisible: false, + isEnabled: false, + }, + + // Transfer conference: not used in digital channels + transferConference: { + isVisible: false, + isEnabled: false, + }, + + // Merge to conference: not used in digital channels + mergeToConference: { + isVisible: false, + isEnabled: false, + }, + }; +} + +/** + * Main function to compute UI controls based on state, context, and config + * + * @param currentState - Current state machine state + * @param context - State machine context + * @returns Computed UI controls + */ +export function computeUIControls( + currentState: TaskState, + context: TaskContext, + fallbackTaskData?: TaskData +): TaskUIControls { + const {uiControlConfig} = context; + + // Route to appropriate channel-specific computation + if (uiControlConfig.channelType === 'voice') { + return computeVoiceUIControls(currentState, context, uiControlConfig, fallbackTaskData); + } + if (uiControlConfig.channelType === 'digital') { + return computeDigitalUIControls(currentState, context, fallbackTaskData); + } + + // Fallback to default (all hidden/disabled) + return getDefaultUIControls(); +} + +/** + * Helper to check if UI controls have changed + */ +export function haveUIControlsChanged( + previous: TaskUIControls | undefined, + next: TaskUIControls +): boolean { + if (!previous) { + return true; + } + + return (Object.keys(next) as (keyof TaskUIControls)[]).some((key) => { + const prev = previous[key]; + const curr = next[key]; + + return prev.isVisible !== curr.isVisible || prev.isEnabled !== curr.isEnabled; + }); +} diff --git a/packages/@webex/contact-center/src/services/task/types.ts b/packages/@webex/contact-center/src/services/task/types.ts index 7ac80a775de..e23cf686b41 100644 --- a/packages/@webex/contact-center/src/services/task/types.ts +++ b/packages/@webex/contact-center/src/services/task/types.ts @@ -2,10 +2,9 @@ // eslint-disable-next-line import/no-unresolved import {CallId} from '@webex/calling/dist/types/common/types'; import EventEmitter from 'events'; -import {Interpreter} from 'xstate'; +import type {AnyActorRef} from 'xstate'; import {Msg} from '../core/GlobalTypes'; import AutoWrapup from './AutoWrapup'; -import {TaskContext, TaskEventPayload} from './state-machine/types'; /** * Unique identifier for a task in the contact center system @@ -209,6 +208,11 @@ export enum TASK_EVENTS { */ TASK_CONSULT_QUEUE_FAILED = 'task:consultQueueFailed', + /** + * Triggered whenever task UI controls are recalculated + */ + TASK_UI_CONTROLS_UPDATED = 'task:ui-controls-updated', + /** * Triggered when a consultation request is accepted * @example @@ -818,75 +822,39 @@ export type TaskData = { }; /** - * Helper class for managing task action control state - * Tracks visibility and enabled state for task actions that can be executed - * @public + * Control state for a single UI action. */ -export class TaskActionControl { - public visible: boolean; - private enabled: boolean; - - constructor(visible: boolean, enabled: boolean) { - this.visible = visible; - this.enabled = enabled; - } - - setVisiblity(visible: boolean): void { - this.visible = visible; - } - - setEnabled(enabled: boolean): void { - this.enabled = enabled; - } - - isVisible(): boolean { - return this.visible; - } - - isEnabled(): boolean { - return this.enabled; - } +export interface TaskUIControlState { + isVisible: boolean; + isEnabled: boolean; } /** - * UI actions configuration for task operations - * Maps each available action to its control state - * This is used by the UI to determine which actions can be performed - * @public - */ -export type TaskUIActions = { - accept: TaskActionControl; - decline: TaskActionControl; - hold: TaskActionControl; - mute: TaskActionControl; - end: TaskActionControl; - transfer: TaskActionControl; - consult: TaskActionControl; - consultTransfer: TaskActionControl; - endConsult: TaskActionControl; - recording: TaskActionControl; - conference: TaskActionControl; - wrapup: TaskActionControl; - /** NEW: Agent exits from an ongoing conference */ - exitConference?: TaskActionControl; - /** NEW: Transfer entire conference to another destination */ - transferConference?: TaskActionControl; - /** NEW: Merge consultation to conference */ - mergeToConference?: TaskActionControl; -}; - -/** - * @deprecated Use TaskActionControl instead - * @public + * UI control configuration for task operations. */ -export const TaskButtonControl = TaskActionControl; +export interface TaskUIControls { + accept: TaskUIControlState; + decline: TaskUIControlState; + hold: TaskUIControlState; + mute: TaskUIControlState; + end: TaskUIControlState; + transfer: TaskUIControlState; + consult: TaskUIControlState; + consultTransfer: TaskUIControlState; + endConsult: TaskUIControlState; + recording: TaskUIControlState; + conference: TaskUIControlState; + wrapup: TaskUIControlState; + exitConference: TaskUIControlState; + transferConference: TaskUIControlState; + mergeToConference: TaskUIControlState; +} /** - * @deprecated Use TaskUIActions instead + * Helper class for managing task action control state + * Tracks visibility and enabled state for task actions that can be executed * @public */ -export type TaskUIControls = TaskUIActions; - /** * Type representing an agent contact message within the contact center system * Contains comprehensive interaction and task related details for agent operations @@ -1275,7 +1243,7 @@ export interface ITask extends EventEmitter { * @see createTaskStateMachine * @internal */ - stateMachineService?: Interpreter; + stateMachineService?: AnyActorRef; state?: any; /** @@ -1485,7 +1453,7 @@ export interface IDigital extends Omit { /** * UI controls configuration */ - taskUiControls: TaskUIActions; + uiControls: TaskUIControls; /** * Updates the task data diff --git a/packages/@webex/contact-center/src/services/task/voice/Voice.ts b/packages/@webex/contact-center/src/services/task/voice/Voice.ts index e260405407c..0fd6b13556e 100644 --- a/packages/@webex/contact-center/src/services/task/voice/Voice.ts +++ b/packages/@webex/contact-center/src/services/task/voice/Voice.ts @@ -18,139 +18,24 @@ import MetricsManager from '../../../metrics/MetricsManager'; import {METRIC_EVENT_NAMES} from '../../../metrics/constants'; import {TaskState, TaskEvent, guards} from '../state-machine'; -export default class Voice extends Task implements IVoice { - private isEndCallEnabled: boolean; - private isEndConsultEnabled: boolean; +export type VoiceUIControlOptions = { + isEndCallEnabled?: boolean; + isEndConsultEnabled?: boolean; + voiceVariant?: 'pstn' | 'webrtc'; +}; +export default class Voice extends Task implements IVoice { constructor( contact: ReturnType, data: TaskData, - callOptions: {isEndCallEnabled?: boolean; isEndConsultEnabled?: boolean} = {} + callOptions: VoiceUIControlOptions = {} ) { - super(contact, data); - // apply defaults when no explicit setting provided - this.isEndCallEnabled = callOptions.isEndCallEnabled ?? true; - this.isEndConsultEnabled = callOptions.isEndConsultEnabled ?? true; - } - - /** - * Compute UI controls based on state machine state. - * This replaces the old updateUIControlsFromState() method. - * Returns plain objects instead of mutating taskUiControls. - */ - protected computeUIControls(): import('../Task').TaskUIControls { - const state = this.stateMachineService?.state; - - if (!state) { - // Fallback if state machine not initialized - return super.computeUIControls(); - } - - // Determine UI control states based on current state and context - const isOffered = state.matches(TaskState.OFFERED) || state.matches(TaskState.OFFERED_CONSULT); - const isConnected = state.matches(TaskState.CONNECTED); - const isHeld = state.matches(TaskState.HELD); - const isConsulting = state.matches(TaskState.CONSULTING); - const isConferencing = state.matches(TaskState.CONFERENCING); - const isWrappingUp = state.matches(TaskState.WRAPPING_UP); - - const context = state.context; - - // Return computed UI controls based on state machine state - return { - // Accept button: visible when offered, always enabled - accept: { - visible: isOffered, - enabled: true, - }, - - // Decline button: visible when offered, always enabled - decline: { - visible: isOffered, - enabled: true, - }, - - // Hold button: visible when connected or held - // Enabled based on current state (hold when connected, resume when held) - hold: { - visible: isConnected || isHeld, - enabled: isConnected || isHeld, - }, - - // Mute button: visible when active call, disabled during wrapup - mute: { - visible: isConnected || isHeld, - enabled: !isWrappingUp, - }, - - // End button: conditional based on config, disabled when held or wrapping up - end: { - visible: this.isEndCallEnabled, - enabled: !isHeld && !isWrappingUp, - }, - - // Transfer button: visible in connected/held/consulting states - transfer: { - visible: isConnected || isHeld || isConsulting, - enabled: true, - }, - - // Consult button: visible when connected or held - // Enabled when in connected or held states (not consulting/conferencing) - consult: { - visible: isConnected || isHeld, - enabled: isConnected || isHeld, - }, - - // Consult transfer: visible during consulting - consultTransfer: { - visible: isConsulting, - enabled: true, - }, - - // End consult button: visible during consulting state - endConsult: { - visible: isConsulting, - enabled: this.isEndConsultEnabled, - }, - - // Recording controls: based on recording state - recording: { - visible: isConnected || isHeld, - enabled: !context.recordingPaused, - }, - - // Conference button: visible during consulting - // Enabled only if consulted agent has joined - conference: { - visible: isConsulting, - enabled: context.consultDestinationAgentJoined, - }, - - // Wrapup button: visible during wrapup state - wrapup: { - visible: isWrappingUp, - enabled: true, - }, - - // Exit conference button: visible during conference - exitConference: { - visible: isConferencing, - enabled: true, - }, - - // Transfer conference: visible during conference - transferConference: { - visible: isConferencing, - enabled: true, - }, - - // Merge to conference: visible during consulting (alias for conference) - mergeToConference: { - visible: isConsulting, - enabled: context.consultDestinationAgentJoined, - }, - }; + super(contact, data, { + channelType: 'voice', + isEndCallEnabled: callOptions.isEndCallEnabled ?? true, + isEndConsultEnabled: callOptions.isEndConsultEnabled ?? true, + voiceVariant: callOptions.voiceVariant ?? 'pstn', + }); } /** @@ -217,7 +102,7 @@ export default class Voice extends Task implements IVoice { const shouldHold = !this.data.interaction.media[this.data.mediaResourceId].isHold; // Validate operation is allowed in current state - const state = this.stateMachineService?.state; + const state = this.stateMachineService?.getSnapshot?.(); if (state) { const currentState = state.value as TaskState; if (shouldHold) { @@ -369,7 +254,7 @@ export default class Voice extends Task implements IVoice { */ public async pauseRecording(): Promise { // Validate recording is active - const state = this.stateMachineService?.state; + const state = this.stateMachineService?.getSnapshot?.(); if (state && !guards.recordingActive({context: state.context})) { const error = new Error('Recording is not active or already paused'); LoggerProxy.error('Pause recording operation not allowed', { @@ -436,7 +321,7 @@ export default class Voice extends Task implements IVoice { resumeRecordingPayload?: ResumeRecordingPayload ): Promise { // Validate recording is paused - const state = this.stateMachineService?.state; + const state = this.stateMachineService?.getSnapshot?.(); if (state && !guards.recordingPaused({context: state.context})) { const error = new Error('Recording is not paused'); LoggerProxy.error('Resume recording operation not allowed', { @@ -510,7 +395,7 @@ export default class Voice extends Task implements IVoice { * */ public async consult(consultPayload?: ConsultPayload): Promise { // Validate consult is allowed - const state = this.stateMachineService?.state; + const state = this.stateMachineService?.getSnapshot?.(); const canConsult = state && (state.matches(TaskState.CONNECTED) || state.matches(TaskState.HELD)); diff --git a/packages/@webex/contact-center/src/services/task/voice/WebRTC.ts b/packages/@webex/contact-center/src/services/task/voice/WebRTC.ts index 72ce96952da..b2e45b903c7 100644 --- a/packages/@webex/contact-center/src/services/task/voice/WebRTC.ts +++ b/packages/@webex/contact-center/src/services/task/voice/WebRTC.ts @@ -3,9 +3,8 @@ import {CC_FILE} from '../../../constants'; import {getErrorDetails} from '../../core/Utils'; import routingContact from '../contact'; import {TaskData, TaskResponse, TASK_EVENTS, IWebRTC} from '../types'; -import Voice from './Voice'; +import Voice, {VoiceUIControlOptions} from './Voice'; import WebCallingService from '../../WebCallingService'; -import {TaskState} from '../state-machine'; import MetricsManager from '../../../metrics/MetricsManager'; import {METRIC_EVENT_NAMES} from '../../../metrics/constants'; import LoggerProxy from '../../../logger-proxy'; @@ -18,9 +17,9 @@ export default class WebRTC extends Voice implements IWebRTC { contact: ReturnType, webCallingService: WebCallingService, data: TaskData, - callOptions: {isEndCallEnabled?: boolean; isEndConsultEnabled?: boolean} = {} + callOptions: VoiceUIControlOptions = {} ) { - super(contact, data, callOptions); + super(contact, data, {...callOptions, voiceVariant: 'webrtc'}); this.webCallingService = webCallingService; this.registerWebCallListeners(); } @@ -33,81 +32,6 @@ export default class WebRTC extends Voice implements IWebRTC { this.emit(TASK_EVENTS.TASK_MEDIA, track); }; - /** - * Compute UI controls for WebRTC tasks. - * Extends Voice UI controls with WebRTC-specific behavior: - * - * 1. Accept/Decline buttons: - * - Visible when task is offered (OFFERED or OFFERED_CONSULT states) - * - Hidden when consulted and in consulting state - * - Hidden when call is terminated - * - * 2. Mute button: - * - Visible when connected or when consulting (if this agent is consulted) - * - Disabled when call is held (can't mute a held call) - * - Hidden during wrapup - * - * WebRTC handles audio client-side, so these controls differ from telephony tasks. - * - * @returns UI control states for all task actions - */ - protected computeUIControls(): import('../Task').TaskUIControls { - // Get base controls from Voice class - const controls = super.computeUIControls(); - - const state = this.stateMachineService?.state; - if (!state) { - return controls; - } - - // Determine current state - const isOffered = state.matches(TaskState.OFFERED) || state.matches(TaskState.OFFERED_CONSULT); - const isConnected = state.matches(TaskState.CONNECTED); - const isHeld = state.matches(TaskState.HELD); - const isConsulting = state.matches(TaskState.CONSULTING); - const isWrappingUp = state.matches(TaskState.WRAPPING_UP); - - // Check if this agent is the consulted party - const isConsultedAgent = this.data.isConsulted ?? false; - - // Check if call is terminated (ended externally while still offered) - const isTerminated = this.data.interaction?.isTerminated ?? false; - - // WebRTC-specific accept/decline logic - // Accept and decline should be visible when: - // - Task is offered (OFFERED or OFFERED_CONSULT state) - // - AND not terminated - // - AND (not consulting OR not the consulted agent) - const showAcceptDecline = isOffered && !isTerminated && (!isConsulting || !isConsultedAgent); - - controls.accept = { - visible: showAcceptDecline, - enabled: showAcceptDecline, - }; - - controls.decline = { - visible: showAcceptDecline, - enabled: showAcceptDecline, - }; - - // WebRTC-specific mute button logic - // Mute should be visible when: - // - Call is connected (active) OR - // - Call is consulting AND this agent is the consulted one - const showMute = isConnected || (isConsulting && isConsultedAgent); - - // Mute should be enabled when: - // - Visible AND not held AND not wrapping up - const enableMute = showMute && !isHeld && !isWrappingUp; - - controls.mute = { - visible: showMute, - enabled: enableMute, - }; - - return controls; - } - /** * This method is used to unregister the web call listeners. * @returns void diff --git a/yarn.lock b/yarn.lock index 1592f3e6bba..45cefb31eb5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9070,8 +9070,8 @@ __metadata: lodash: ^4.17.21 prettier: 2.5.1 typedoc: ^0.25.0 - typescript: 4.9.5 - xstate: ^4.38.0 + typescript: 5.4.5 + xstate: 5.24.0 languageName: unknown linkType: soft @@ -34150,6 +34150,16 @@ __metadata: languageName: node linkType: hard +"typescript@npm:5.4.5": + version: 5.4.5 + resolution: "typescript@npm:5.4.5" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 53c879c6fa1e3bcb194b274d4501ba1985894b2c2692fa079db03c5a5a7140587a1e04e1ba03184605d35f439b40192d9e138eb3279ca8eee313c081c8bcd9b0 + languageName: node + linkType: hard + "typescript@npm:^4.6.4 || ^5.2.2, typescript@npm:^5.0.4": version: 5.3.2 resolution: "typescript@npm:5.3.2" @@ -34160,6 +34170,16 @@ __metadata: languageName: node linkType: hard +"typescript@npm:^5.4.5": + version: 5.9.3 + resolution: "typescript@npm:5.9.3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 0d0ffb84f2cd072c3e164c79a2e5a1a1f4f168e84cb2882ff8967b92afe1def6c2a91f6838fb58b168428f9458c57a2ba06a6737711fdd87a256bbe83e9a217f + languageName: node + linkType: hard + "typescript@npm:^5.6.3": version: 5.6.3 resolution: "typescript@npm:5.6.3" @@ -34200,6 +34220,16 @@ __metadata: languageName: node linkType: hard +"typescript@patch:typescript@5.4.5#~builtin": + version: 5.4.5 + resolution: "typescript@patch:typescript@npm%3A5.4.5#~builtin::version=5.4.5&hash=1f5320" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 2373c693f3b328f3b2387c3efafe6d257b057a142f9a79291854b14ff4d5367d3d730810aee981726b677ae0fd8329b23309da3b6aaab8263dbdccf1da07a3ba + languageName: node + linkType: hard + "typescript@patch:typescript@^4.6.4 || ^5.2.2#~builtin, typescript@patch:typescript@^5.0.4#~builtin": version: 5.3.2 resolution: "typescript@patch:typescript@npm%3A5.3.2#~builtin::version=5.3.2&hash=1f5320" @@ -34210,6 +34240,16 @@ __metadata: languageName: node linkType: hard +"typescript@patch:typescript@^5.4.5#~builtin": + version: 5.9.3 + resolution: "typescript@patch:typescript@npm%3A5.9.3#~builtin::version=5.9.3&hash=1f5320" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 8bb8d86819ac86a498eada254cad7fb69c5f74778506c700c2a712daeaff21d3a6f51fd0d534fe16903cb010d1b74f89437a3d02d4d0ff5ca2ba9a4660de8497 + languageName: node + linkType: hard + "typescript@patch:typescript@^5.6.3#~builtin": version: 5.6.3 resolution: "typescript@patch:typescript@npm%3A5.6.3#~builtin::version=5.6.3&hash=1f5320" @@ -35584,7 +35624,7 @@ __metadata: standard-version: ^9.1.1 terser-webpack-plugin: ^4.2.3 ts-jest: ^29.0.3 - typescript: ^4.7.4 + typescript: ^5.4.5 uuid: ^3.3.2 wd: ^1.14.0 wdio-chromedriver-service: ^7.3.2 @@ -36445,7 +36485,14 @@ __metadata: languageName: node linkType: hard -"xstate@npm:^4.30.6, xstate@npm:^4.38.0": +"xstate@npm:5.24.0": + version: 5.24.0 + resolution: "xstate@npm:5.24.0" + checksum: ed3eca9bdf46ca3642761e989d4c212f4c63c06ffeba36c969965dcde8fd230cc0a62936cf4858e00bf179863254658caf435fc12d497a0f461ec7bee67d2d5a + languageName: node + linkType: hard + +"xstate@npm:^4.30.6": version: 4.38.3 resolution: "xstate@npm:4.38.3" checksum: b52e5bf349834ede65b1eadf9b160b818341739b1306e882c35dd6c4ddb92f18342f534d5080c5218f935254230721faca3d34b66cbb3b6f19d8496516f23eca From 9cb7cd36afdc91995a482c606890f898b45a4b84 Mon Sep 17 00:00:00 2001 From: arungane Date: Sat, 22 Nov 2025 22:38:11 -0500 Subject: [PATCH 09/14] fix(contract center): recording flag enabled --- .../src/services/config/types.ts | 90 ++++++++ .../src/services/task/TaskFactory.ts | 13 +- .../src/services/task/TaskManager.ts | 7 + .../src/services/task/digital/Digital.ts | 1 + .../services/task/state-machine/actions.ts | 90 ++++---- .../src/services/task/state-machine/guards.ts | 4 +- .../src/services/task/state-machine/types.ts | 8 +- .../task/state-machine/uiControlsComputer.ts | 19 +- .../src/services/task/taskDataNormalizer.ts | 139 ++++++++++++ .../contact-center/src/services/task/types.ts | 199 +++++++++++++++--- .../src/services/task/voice/Voice.ts | 2 + packages/@webex/contact-center/src/types.ts | 5 + 12 files changed, 476 insertions(+), 101 deletions(-) create mode 100644 packages/@webex/contact-center/src/services/task/taskDataNormalizer.ts diff --git a/packages/@webex/contact-center/src/services/config/types.ts b/packages/@webex/contact-center/src/services/config/types.ts index b6b07c66a1c..ccfbb17d561 100644 --- a/packages/@webex/contact-center/src/services/config/types.ts +++ b/packages/@webex/contact-center/src/services/config/types.ts @@ -275,6 +275,31 @@ export type AgentResponse = { * Represents the response from getDesktopProfileById method. */ export type DesktopProfileResponse = { + /** + * Unique identifier of the agent profile configuration. + */ + id: string; + + /** + * Display name for the agent profile. + */ + name: string; + + /** + * Description of the agent profile. + */ + description: string; + + /** + * Parent entity type for the profile (for example ORGANIZATION). + */ + parentType: string; + + /** + * Indicates whether screen pop is enabled. + */ + screenPopup: boolean; + /** * Represents the voice options of an agent. */ @@ -315,6 +340,11 @@ export type DesktopProfileResponse = { */ autoWrapUp: boolean; + /** + * Whether the agent personal greeting is enabled. + */ + agentPersonalGreeting: boolean; + /** * Auto answer allowed. */ @@ -335,6 +365,36 @@ export type DesktopProfileResponse = { */ allowAutoWrapUpExtension: boolean; + /** + * Access control for queues assigned to the agent (ALL or SPECIFIC). + */ + accessQueue: string; + + /** + * Queue identifiers available to the agent when access is SPECIFIC. + */ + queues: string[]; + + /** + * Access control for entry points assigned to the agent. + */ + accessEntryPoint: string; + + /** + * Entry point identifiers available to the agent when access is SPECIFIC. + */ + entryPoints: string[]; + + /** + * Access control for buddy teams assigned to the agent. + */ + accessBuddyTeam: string; + + /** + * Buddy team identifiers available to the agent when access is SPECIFIC. + */ + buddyTeams: string[]; + /** * Outdial enabled for the agent. */ @@ -378,6 +438,11 @@ export type DesktopProfileResponse = { */ agentDNValidation: string; + /** + * Additional DN validation criteria configured for the agent. + */ + agentDNValidationCriterions: string[]; + /** * Dial plans of the agent. */ @@ -412,6 +477,31 @@ export type DesktopProfileResponse = { * State synchronization in Webex enabled or not. */ stateSynchronizationWebex: boolean; + + /** + * Threshold rules configured for the agent profile. + */ + thresholdRules: Array>; + + /** + * Whether the agent profile is currently active. + */ + active: boolean; + + /** + * Whether this profile is the system default. + */ + systemDefault: boolean; + + /** + * Timestamp when the profile was created. + */ + createdTime: number; + + /** + * Timestamp when the profile was last updated. + */ + lastUpdatedTime: number; }; /** diff --git a/packages/@webex/contact-center/src/services/task/TaskFactory.ts b/packages/@webex/contact-center/src/services/task/TaskFactory.ts index 0594a337d85..a755da19335 100644 --- a/packages/@webex/contact-center/src/services/task/TaskFactory.ts +++ b/packages/@webex/contact-center/src/services/task/TaskFactory.ts @@ -19,17 +19,20 @@ export default class TaskFactory { ): Task { const mediaType = data.interaction.mediaType ?? MEDIA_CHANNEL.TELEPHONY; const {isEndCallEnabled, isEndConsultEnabled} = configFlags; + const recordingEnabled = data?.interaction?.callProcessingDetails?.pauseResumeEnabled ?? true; + const voiceControlOptions = { + isEndCallEnabled, + isEndConsultEnabled, + isRecordingEnabled: recordingEnabled, + }; switch (mediaType) { case MEDIA_CHANNEL.TELEPHONY: if (webCallingService.loginOption === 'BROWSER') { - return new WebRTC(contact, webCallingService, data); + return new WebRTC(contact, webCallingService, data, voiceControlOptions); } - return new Voice(contact, data, { - isEndCallEnabled, - isEndConsultEnabled, - }); + return new Voice(contact, data, voiceControlOptions); case MEDIA_CHANNEL.CHAT: case MEDIA_CHANNEL.EMAIL: diff --git a/packages/@webex/contact-center/src/services/task/TaskManager.ts b/packages/@webex/contact-center/src/services/task/TaskManager.ts index ff7c3e99a36..601264663d0 100644 --- a/packages/@webex/contact-center/src/services/task/TaskManager.ts +++ b/packages/@webex/contact-center/src/services/task/TaskManager.ts @@ -14,6 +14,7 @@ import {METRIC_EVENT_NAMES} from '../../metrics/constants'; import TaskFactory from './TaskFactory'; import WebRTC from './voice/WebRTC'; import {TaskEvent, type TaskEventPayload} from './state-machine'; +import {normalizeTaskData} from './taskDataNormalizer'; /** @internal */ export default class TaskManager extends EventEmitter { private call: ICall; @@ -205,6 +206,12 @@ export default class TaskManager extends EventEmitter { private registerTaskListeners() { this.webSocketManager.on('message', (event) => { const payload = JSON.parse(event); + if (payload?.keepalive === 'true' || payload?.keepalive === true) { + return; + } + if (payload?.data?.interaction) { + payload.data = normalizeTaskData(payload.data); + } // Re-emit the task events to the task object let task: ITask; if (payload.data?.type) { diff --git a/packages/@webex/contact-center/src/services/task/digital/Digital.ts b/packages/@webex/contact-center/src/services/task/digital/Digital.ts index a765724eb5c..7e58ed02910 100644 --- a/packages/@webex/contact-center/src/services/task/digital/Digital.ts +++ b/packages/@webex/contact-center/src/services/task/digital/Digital.ts @@ -13,6 +13,7 @@ export default class Digital extends Task implements IDigital { channelType: 'digital', isEndCallEnabled: true, isEndConsultEnabled: false, + isRecordingEnabled: false, }); } diff --git a/packages/@webex/contact-center/src/services/task/state-machine/actions.ts b/packages/@webex/contact-center/src/services/task/state-machine/actions.ts index 769ea1ac3fc..d75749fd38c 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/actions.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/actions.ts @@ -39,8 +39,8 @@ export function createInitialContext( consultInitiator: false, consultDestination: null, consultDestinationAgentJoined: false, - recordingActive: false, - recordingPaused: false, + recordingControlsAvailable: false, + recordingInProgress: false, uiControlConfig, uiControls: getDefaultUIControls(), }; @@ -74,10 +74,7 @@ export const actions = { */ initializeTask: assign((context: TaskContext, event: TaskEventPayload) => { if (isEventOfType(event, TaskEvent.OFFER) || isEventOfType(event, TaskEvent.OFFER_CONSULT)) { - return { - taskData: event.taskData, - ...deriveRecordingState(event.taskData), - }; + return deriveTaskDataUpdates(context, event.taskData); } return {}; @@ -88,16 +85,10 @@ export const actions = { */ updateTaskData: assign((context: TaskContext, event: TaskEventPayload) => { if (isEventOfType(event, TaskEvent.ASSIGN)) { - return { - taskData: event.taskData, - ...deriveRecordingState(event.taskData), - }; + return deriveTaskDataUpdates(context, event.taskData); } if (isEventOfType(event, TaskEvent.CONSULT_CREATED)) { - return { - taskData: event.taskData, - ...deriveRecordingState(event.taskData), - }; + return deriveTaskDataUpdates(context, event.taskData); } return {}; @@ -142,12 +133,14 @@ export const actions = { setRecordingState: assign((context: TaskContext, event: TaskEventPayload) => { if (isEventOfType(event, TaskEvent.PAUSE_RECORDING)) { return { - recordingPaused: true, + recordingControlsAvailable: true, + recordingInProgress: false, }; } if (isEventOfType(event, TaskEvent.RESUME_RECORDING)) { return { - recordingPaused: false, + recordingControlsAvailable: true, + recordingInProgress: true, }; } @@ -204,8 +197,8 @@ export const actions = { * Mark task as ended (currently no-op placeholder) */ markEnded: assign(() => ({ - recordingActive: false, - recordingPaused: false, + recordingControlsAvailable: false, + recordingInProgress: false, })), /** @@ -216,26 +209,9 @@ export const actions = { }, }; -type RecordingStateUpdate = Partial>; - -const parseBooleanFlag = (value?: string | boolean | null): boolean | undefined => { - if (typeof value === 'boolean') { - return value; - } - - if (typeof value === 'string') { - const normalized = value.toLowerCase(); - - if (normalized === 'true') { - return true; - } - if (normalized === 'false') { - return false; - } - } - - return undefined; -}; +type RecordingStateUpdate = Partial< + Pick +>; const deriveRecordingState = (taskData?: TaskData | null): RecordingStateUpdate => { const callProcessingDetails = taskData?.interaction?.callProcessingDetails; @@ -245,31 +221,37 @@ const deriveRecordingState = (taskData?: TaskData | null): RecordingStateUpdate } const update: RecordingStateUpdate = {}; - const recordInProgress = parseBooleanFlag( - callProcessingDetails.recordInProgress ?? callProcessingDetails.recordingStarted - ); - const isPaused = parseBooleanFlag(callProcessingDetails.isPaused); - - if (typeof recordInProgress !== 'undefined') { - update.recordingActive = recordInProgress; - if (!recordInProgress) { - update.recordingPaused = false; - } else if (typeof isPaused === 'undefined') { - update.recordingPaused = false; + const {recordingStarted, recordInProgress} = callProcessingDetails; + + if (recordingStarted !== undefined) { + update.recordingControlsAvailable = recordingStarted; + if (!recordingStarted) { + update.recordingInProgress = false; } } - if (typeof isPaused !== 'undefined') { - update.recordingPaused = isPaused; + if (recordInProgress !== undefined) { + update.recordingControlsAvailable = recordInProgress || recordingStarted || false; + update.recordingInProgress = recordInProgress; + } - if (isPaused) { - update.recordingActive = true; - } + if ( + update.recordingControlsAvailable === undefined && + update.recordingInProgress === undefined && + recordingStarted + ) { + update.recordingControlsAvailable = true; + update.recordingInProgress = true; } return update; }; +const deriveTaskDataUpdates = (_context: TaskContext, taskData: TaskData) => ({ + taskData, + ...deriveRecordingState(taskData), +}); + /** * Helper to create action implementations that will be used by Task/Voice classes * These factories allow the Task/Voice class to inject their own logic while keeping diff --git a/packages/@webex/contact-center/src/services/task/state-machine/guards.ts b/packages/@webex/contact-center/src/services/task/state-machine/guards.ts index c967633a49d..2a2ad3f0714 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/guards.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/guards.ts @@ -37,13 +37,13 @@ export const guards = { * Check if recording is active */ recordingActive: ({context}: GuardParams): boolean => { - return context.recordingActive && !context.recordingPaused; + return context.recordingControlsAvailable && context.recordingInProgress; }, /** * Check if recording is paused */ recordingPaused: ({context}: GuardParams): boolean => { - return context.recordingActive && context.recordingPaused; + return context.recordingControlsAvailable && !context.recordingInProgress; }, }; diff --git a/packages/@webex/contact-center/src/services/task/state-machine/types.ts b/packages/@webex/contact-center/src/services/task/state-machine/types.ts index fa2d59c393b..504c3769dae 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/types.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/types.ts @@ -136,6 +136,8 @@ export interface UIControlConfig { channelType: 'voice' | 'digital'; /** Optional voice channel variant to toggle WebRTC-specific controls */ voiceVariant?: 'pstn' | 'webrtc'; + /** Whether recording controls should be shown for this task */ + isRecordingEnabled: boolean; } /** @@ -164,9 +166,9 @@ export interface TaskContext { consultDestination: string | null; consultDestinationAgentJoined: boolean; - // Recording tracking - recordingActive: boolean; - recordingPaused: boolean; + // Recording tracking derived from task data + recordingControlsAvailable: boolean; + recordingInProgress: boolean; // UI Control configuration (set at task creation) uiControlConfig: UIControlConfig; diff --git a/packages/@webex/contact-center/src/services/task/state-machine/uiControlsComputer.ts b/packages/@webex/contact-center/src/services/task/state-machine/uiControlsComputer.ts index 8de99d89a32..4c861e96bb4 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/uiControlsComputer.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/uiControlsComputer.ts @@ -10,6 +10,18 @@ import {TaskData, TaskUIControls} from '../types'; import {TaskState, TaskContext, UIControlConfig} from './types'; +type RecordingControlState = { + available: boolean; + inProgress: boolean; +}; + +function getRecordingControlState(context: TaskContext): RecordingControlState { + return { + available: Boolean(context.recordingControlsAvailable), + inProgress: Boolean(context.recordingInProgress), + }; +} + /** * Get default UI controls (all hidden/disabled) */ @@ -53,6 +65,9 @@ function computeVoiceUIControls( const taskData = context.taskData ?? fallbackTaskData ?? null; const isConsultedAgent = Boolean(taskData?.isConsulted); const isTerminated = taskData?.interaction?.isTerminated ?? false; + const {available: recordingAvailable, inProgress: recordingInProgress} = + getRecordingControlState(context); + const recordingFeatureEnabled = config.channelType === 'voice' && config.isRecordingEnabled; const shouldShowAcceptDecline = isWebrtc ? isOffered && !isTerminated && (!isConsulting || !isConsultedAgent) : isOffered; @@ -120,8 +135,8 @@ function computeVoiceUIControls( // Recording controls: based on recording state recording: { - isVisible: isConnected || isHeld, - isEnabled: !context.recordingPaused, + isVisible: recordingAvailable && recordingFeatureEnabled && (isConnected || isHeld), + isEnabled: recordingAvailable && recordingFeatureEnabled && recordingInProgress, }, // Conference button: visible during consulting diff --git a/packages/@webex/contact-center/src/services/task/taskDataNormalizer.ts b/packages/@webex/contact-center/src/services/task/taskDataNormalizer.ts new file mode 100644 index 00000000000..797a4fba7dd --- /dev/null +++ b/packages/@webex/contact-center/src/services/task/taskDataNormalizer.ts @@ -0,0 +1,139 @@ +import {TaskData} from './types'; + +type BooleanKey = + | 'recordingStarted' + | 'recordInProgress' + | 'isPaused' + | 'pauseResumeEnabled' + | 'ctqInProgress' + | 'outdialTransferToQueueEnabled' + | 'taskToBeSelfServiced' + | 'CONTINUE_RECORDING_ON_TRANSFER' + | 'isParked' + | 'participantInviteTimeout' + | 'checkAgentAvailability'; + +const booleanKeys: BooleanKey[] = [ + 'recordingStarted', + 'recordInProgress', + 'isPaused', + 'pauseResumeEnabled', + 'ctqInProgress', + 'outdialTransferToQueueEnabled', + 'taskToBeSelfServiced', + 'CONTINUE_RECORDING_ON_TRANSFER', + 'isParked', + 'participantInviteTimeout', + 'checkAgentAvailability', +]; + +const interactionBooleanKeys: Array = [ + 'isFcManaged', + 'isMediaForked', + 'isTerminated', +]; + +const participantBooleanKeys = [ + 'autoAnswerEnabled', + 'hasJoined', + 'hasLeft', + 'isConsulted', + 'isInPredial', + 'isOffered', + 'isWrapUp', + 'isWrappedUp', +]; + +const toBoolean = (value: unknown): boolean | undefined => { + if (typeof value === 'boolean') { + return value; + } + + if (typeof value === 'string') { + if (value.toLowerCase() === 'true') { + return true; + } + if (value.toLowerCase() === 'false') { + return false; + } + } + + return undefined; +}; + +const normalizeFields = >(obj: T, keys: string[]): T | undefined => { + let updated: T | undefined; + + keys.forEach((key) => { + const normalized = toBoolean(obj[key]); + + if (typeof normalized !== 'undefined') { + if (!updated) { + updated = {...obj}; + } + (updated as any)[key] = normalized; + } + }); + + return updated; +}; + +/** + * Normalize backend task payload quirks so downstream code can rely on actual booleans. + * + * Applies to every Agent Contact websocket event before it reaches the state machine: + * - Converts string booleans in callProcessingDetails to actual booleans. + * - Also normalizes known boolean fields on interaction and participants. + * - Keeps payload shape intact; only coerces known boolean fields. + */ +export function normalizeTaskData(data: TaskData): TaskData { + const interaction = data?.interaction; + + if (!interaction) { + return data; + } + + const details = interaction.callProcessingDetails; + const updatedDetails = details ? normalizeFields(details, booleanKeys) : undefined; + const updatedInteractionBooleans = normalizeFields( + interaction, + interactionBooleanKeys as string[] + ); + + let updatedParticipants: typeof interaction.participants | undefined; + Object.entries(interaction.participants || {}).forEach(([id, participant]) => { + const normalized = normalizeFields(participant, participantBooleanKeys); + if (normalized) { + if (!updatedParticipants) { + updatedParticipants = {...interaction.participants}; + } + updatedParticipants[id] = normalized; + } + }); + + let updatedMedia: typeof interaction.media | undefined; + Object.entries(interaction.media || {}).forEach(([id, media]) => { + const normalized = normalizeFields(media, ['isHold']); + if (normalized) { + if (!updatedMedia) { + updatedMedia = {...interaction.media}; + } + updatedMedia[id] = normalized; + } + }); + + if (!updatedDetails && !updatedInteractionBooleans && !updatedParticipants && !updatedMedia) { + return data; + } + + return { + ...data, + interaction: { + ...interaction, + ...(updatedInteractionBooleans || {}), + callProcessingDetails: updatedDetails || details, + participants: updatedParticipants || interaction.participants, + media: updatedMedia || interaction.media, + }, + }; +} diff --git a/packages/@webex/contact-center/src/services/task/types.ts b/packages/@webex/contact-center/src/services/task/types.ts index e23cf686b41..1f2a80c7a66 100644 --- a/packages/@webex/contact-center/src/services/task/types.ts +++ b/packages/@webex/contact-center/src/services/task/types.ts @@ -507,6 +507,125 @@ export enum TASK_EVENTS { * Contains comprehensive details about an ongoing customer interaction * @public */ +export interface CallAssociatedDatum { + /** Whether the field can be edited by the agent */ + agentEditable: boolean; + /** Whether the field is visible to the agent */ + agentViewable: boolean; + /** Display name for the field */ + displayName: string; + /** Whether the field is global */ + global: boolean; + /** Whether the field is secure */ + isSecure: boolean; + /** Internal field name */ + name: string; + /** Whether the field is reportable */ + reportable: boolean; + /** Secure key identifier */ + secureKeyId: string; + /** Secure key version */ + secureKeyVersion: number; + /** Data type of the field */ + type: string; + /** Field value */ + value: string; +} + +export type CallAssociatedData = Record; + +export type CallAssociatedDetails = Record; + +export interface FlowParameter { + /** Parameter name */ + name?: string; + /** Additional qualifier */ + qualifier?: string; + /** Description of the parameter */ + description?: string; + /** Data type of the value */ + valueDataType?: string; + /** Value associated with the parameter */ + value?: string; +} + +export interface InteractionParticipant { + /** Unique participant identifier */ + id: string; + /** Participant type label used by backend */ + pType: string; + /** Friendly participant type */ + type: string; + /** Whether the participant has joined */ + hasJoined: boolean; + /** Whether the participant has left */ + hasLeft: boolean; + /** Whether the participant is still in pre-dial */ + isInPredial: boolean; + /** Optional caller identifier */ + callerId?: string | null; + /** Whether auto-answer is enabled */ + autoAnswerEnabled?: boolean; + /** Backchannel/bnr details */ + bnrDetails?: unknown; + /** Channel identifier for the participant */ + channelId?: string; + /** Current consult state */ + consultState?: string | null; + /** Timestamp when consult started */ + consultTimestamp?: number | null; + /** Current participant state */ + currentState?: string | null; + /** Timestamp of the current state */ + currentStateTimestamp?: number | null; + /** Device call identifier */ + deviceCallId?: string | null; + /** Device identifier */ + deviceId?: string | null; + /** Device type (AGENT_DN, BROWSER, etc.) */ + deviceType?: string | null; + /** Dial number associated with participant */ + dn?: string | null; + /** Whether participant is currently consulted */ + isConsulted?: boolean; + /** Whether participant offer is active */ + isOffered?: boolean; + /** Whether participant is in wrap-up */ + isWrapUp?: boolean; + /** Whether participant completed wrap-up */ + isWrappedUp?: boolean; + /** Timestamp of when participant joined */ + joinTimestamp?: number | null; + /** Last updated timestamp */ + lastUpdated?: number | null; + /** Friendly name of participant */ + name?: string | null; + /** Queue identifier associated with participant */ + queueId?: string; + /** Queue manager identifier */ + queueMgrId?: string; + /** Session identifier */ + sessionId?: string; + /** Site identifier */ + siteId?: string; + /** Skill identifier */ + skillId?: string | null; + /** Skill name */ + skillName?: string | null; + /** Skill list for participant */ + skills?: string[]; + /** Team identifier */ + teamId?: string; + /** Team name */ + teamName?: string; + /** Timestamp for wrap-up */ + wrapUpTimestamp?: number | null; + /** Additional metadata */ + [key: string]: unknown; +} + +export type InteractionParticipants = Record; + export type Interaction = { /** Indicates if the interaction is managed by Flow Control */ isFcManaged: boolean; @@ -521,7 +640,11 @@ export type Interaction = { /** Current virtual team handling the interaction */ currentVTeam: string; /** List of participants in the interaction */ - participants: any; // TODO: Define specific participant type + participants: InteractionParticipants; + /** Detailed call associated data */ + callAssociatedData?: CallAssociatedData; + /** Simplified call associated key/value pairs */ + callAssociatedDetails?: CallAssociatedDetails; /** Unique identifier for the interaction */ interactionId: string; /** Organization identifier */ @@ -530,7 +653,18 @@ export type Interaction = { createdTimestamp?: number; /** Indicates if wrap-up assistance is enabled */ isWrapUpAssist?: boolean; - /** Detailed call processing information and metadata */ + /** Identifier of parent interaction if applicable */ + parentInteractionId?: string; + /** Indicates if media is forked for this interaction */ + isMediaForked?: boolean; + /** Retroactive flow properties returned by backend */ + flowProperties?: Record | null; + /** Media specific properties returned by backend */ + mediaProperties?: Record | null; + /** + * Detailed call processing information and metadata. + * Mirrors the callProcessingDetails section described in Webex Contact Center Agent Contact payloads. + */ callProcessingDetails: { /** Name of the Queue Manager handling this interaction */ QMgrName: string; @@ -548,20 +682,24 @@ export type Interaction = { QueueId: string; /** Virtual team identifier */ vteamId: string; - /** Indicates if pause/resume functionality is enabled */ - pauseResumeEnabled?: string; + /** Agent capability for pause/resume on this interaction */ + pauseResumeEnabled?: boolean; /** Duration of pause in seconds */ pauseDuration?: string; - /** Indicates if the interaction is currently paused */ - isPaused?: string; - /** Indicates if recording is in progress */ - recordInProgress?: string; - /** Indicates if recording has started */ - recordingStarted?: string; + /** Legacy pause indicator (recordInProgress=false is the active pause signal) */ + isPaused?: boolean; + /** Recording is actively capturing audio right now */ + recordInProgress?: boolean; + /** Recording was started for this interaction (may be paused) */ + recordingStarted?: boolean; + /** Customer geographic region */ + customerRegion?: string; + /** Flow tag identifier */ + flowTagId?: string; /** Indicates if Consult to Queue is in progress */ - ctqInProgress?: string; + ctqInProgress?: boolean; /** Indicates if outdial transfer to queue is enabled */ - outdialTransferToQueueEnabled?: string; + outdialTransferToQueueEnabled?: boolean; /** IVR conversation transcript */ convIvrTranscript?: string; /** Customer's name */ @@ -649,6 +787,8 @@ export type Interaction = { }; /** Main interaction identifier for related interactions */ mainInteractionId?: string; + /** Timestamp when interaction entered queue */ + queuedTimestamp?: number | null; /** Media-specific information for the interaction */ media: Record< string, @@ -672,38 +812,27 @@ export type Interaction = { /** Owner of the interaction */ owner: string; /** Primary media channel for the interaction */ - mediaChannel: MEDIA_CHANNEL; + mediaChannel: string; /** Direction information for the contact */ contactDirection: {type: string}; /** Type of outbound interaction */ outboundType?: string; + /** Optional workflow manager identifier */ + workflowManager?: string | null; /** Parameters passed through the call flow */ - callFlowParams: Record< - string, - { - /** Name of the parameter */ - name: string; - /** Qualifier for the parameter */ - qualifier: string; - /** Description of the parameter */ - description: string; - /** Data type of the parameter value */ - valueDataType: string; - /** Value of the parameter */ - value: string; - } - >; + callFlowParams?: Record; }; /** - * Task payload containing detailed information about a contact center task - * This structure encapsulates all relevant data for task management + * Task payload mirroring the Agent Contact event payload from Webex Contact Center + * (developer.webex.com). Arrives on AGENT_* websocket events and is the source of truth + * for UI/state machine updates. * @public */ export type TaskData = { - /** Unique identifier for the media resource handling this task */ + /** Primary media resource identifier for the active leg (matches interaction.media[].mediaResourceId) */ mediaResourceId: string; - /** Type of event that triggered this task data */ + /** Agent event name from the websocket stream (e.g., AGENT_CONTACT_ASSIGNED) */ eventType: string; /** Timestamp when the event occurred */ eventTime?: number; @@ -713,7 +842,7 @@ export type TaskData = { destAgentId: string; /** Unique tracking identifier for the task */ trackingId: string; - /** Media resource identifier for consultation operations */ + /** Media resource identifier for consultation leg when present */ consultMediaResourceId: string; /** Detailed interaction information */ interaction: Interaction; @@ -725,7 +854,7 @@ export type TaskData = { toOwner?: boolean; /** Identifier for child interaction in consult/transfer scenarios */ childInteractionId?: string; - /** Unique identifier for the interaction */ + /** Interaction/contact identifier from backend (same as interaction.interactionId) */ interactionId: string; /** Organization identifier */ orgId: string; @@ -735,7 +864,7 @@ export type TaskData = { queueMgr: string; /** Name of the queue where task is queued */ queueName?: string; - /** Type of the task */ + /** Task/interaction type returned by the platform (routing/monitoring/etc.) */ type: string; /** Timeout value for RONA (Redirection on No Answer) in seconds */ ronaTimeout?: number; diff --git a/packages/@webex/contact-center/src/services/task/voice/Voice.ts b/packages/@webex/contact-center/src/services/task/voice/Voice.ts index 0fd6b13556e..b7bce36eab6 100644 --- a/packages/@webex/contact-center/src/services/task/voice/Voice.ts +++ b/packages/@webex/contact-center/src/services/task/voice/Voice.ts @@ -22,6 +22,7 @@ export type VoiceUIControlOptions = { isEndCallEnabled?: boolean; isEndConsultEnabled?: boolean; voiceVariant?: 'pstn' | 'webrtc'; + isRecordingEnabled?: boolean; }; export default class Voice extends Task implements IVoice { @@ -35,6 +36,7 @@ export default class Voice extends Task implements IVoice { isEndCallEnabled: callOptions.isEndCallEnabled ?? true, isEndConsultEnabled: callOptions.isEndConsultEnabled ?? true, voiceVariant: callOptions.voiceVariant ?? 'pstn', + isRecordingEnabled: callOptions.isRecordingEnabled ?? true, }); } diff --git a/packages/@webex/contact-center/src/types.ts b/packages/@webex/contact-center/src/types.ts index 643e69386fc..6085682f09c 100644 --- a/packages/@webex/contact-center/src/types.ts +++ b/packages/@webex/contact-center/src/types.ts @@ -574,6 +574,11 @@ export type ConfigFlags = { isEndConsultEnabled: boolean; webRtcEnabled: boolean; autoWrapup: boolean; + /** + * Optional toggle to globally enable/disable recording controls. + * Falls back to backend hints when omitted. + */ + isRecordingEnabled?: boolean; }; /** From a3825777a5835ba59f3cff4a2b4dac1f444af453 Mon Sep 17 00:00:00 2001 From: arungane Date: Sat, 29 Nov 2025 15:57:47 +0530 Subject: [PATCH 10/14] fix(contact-center): update the code with review comments --- .../src/services/config/types.ts | 20 +- .../contact-center/src/services/task/Task.ts | 41 +-- .../src/services/task/TaskManager.ts | 52 ++- .../task/state-machine/TaskStateMachine.ts | 29 +- .../services/task/state-machine/actions.ts | 310 ++++++++---------- .../src/services/task/state-machine/index.ts | 9 +- .../src/services/task/state-machine/types.ts | 2 + .../contact-center/src/services/task/types.ts | 11 + .../src/services/task/voice/Voice.ts | 28 +- 9 files changed, 235 insertions(+), 267 deletions(-) diff --git a/packages/@webex/contact-center/src/services/config/types.ts b/packages/@webex/contact-center/src/services/config/types.ts index ccfbb17d561..0d5edfc5459 100644 --- a/packages/@webex/contact-center/src/services/config/types.ts +++ b/packages/@webex/contact-center/src/services/config/types.ts @@ -83,6 +83,8 @@ export const CC_TASK_EVENTS = { AGENT_CONSULT_TRANSFER_FAILED: 'AgentConsultTransferFailed', /** Event emitted when contact recording is paused */ CONTACT_RECORDING_PAUSED: 'ContactRecordingPaused', + /** Event emitted when contact recording is started */ + CONTACT_RECORDING_STARTED: 'ContactRecordingStarted', /** Event emitted when pausing contact recording fails */ CONTACT_RECORDING_PAUSE_FAILED: 'ContactRecordingPauseFailed', /** Event emitted when contact recording is resumed */ @@ -177,6 +179,15 @@ export type WelcomeEvent = { agentId: string; }; +/** + * Available login options for voice channel access + * 'AGENT_DN' - Login using agent's DN + * 'EXTENSION' - Login using extension number + * 'BROWSER' - Login using browser-based WebRTC + * @public + */ +export type LoginOption = 'AGENT_DN' | 'EXTENSION' | 'BROWSER'; + /** * Response type for welcome events which can be either success or error * @public @@ -946,15 +957,6 @@ export type WrapupData = { }; }; -/** - * Available login options for voice channel access - * 'AGENT_DN' - Login using agent's DN - * 'EXTENSION' - Login using extension number - * 'BROWSER' - Login using browser-based WebRTC - * @public - */ -export type LoginOption = 'AGENT_DN' | 'EXTENSION' | 'BROWSER'; - /** * Team configuration information * @public diff --git a/packages/@webex/contact-center/src/services/task/Task.ts b/packages/@webex/contact-center/src/services/task/Task.ts index 8b4614715c8..b0a8f5581bc 100644 --- a/packages/@webex/contact-center/src/services/task/Task.ts +++ b/packages/@webex/contact-center/src/services/task/Task.ts @@ -1,7 +1,6 @@ import {EventEmitter} from 'events'; -import {CallId} from '@webex/calling/dist/types/common/types'; import {createActor} from 'xstate'; -import type {ActorRefFrom} from 'xstate'; +import type {ActorRefFrom, SnapshotFrom} from 'xstate'; import { ITask, TaskData, @@ -20,12 +19,10 @@ import MetricsManager from '../../metrics/MetricsManager'; import {METRIC_EVENT_NAMES} from '../../metrics/constants'; import LoggerProxy from '../../logger-proxy'; import { - createTaskStateMachineWithActions, - createActionsWithCallbacks, + createTaskStateMachine, TaskState, TaskEventPayload, type TaskStateMachine, - type ActionCallbacks, type UIControlConfig, type TaskContext, } from './state-machine'; @@ -50,13 +47,15 @@ export type Participant = { */ export type TaskAccessorParticipant = Participant; +type CallId = string; + export default abstract class Task extends EventEmitter implements ITask { protected contact: ReturnType; protected metricsManager: MetricsManager; public stateMachineService?: ActorRefFrom; public data: TaskData; public webCallMap: Record; - public state: any; + public state?: SnapshotFrom; private lastState: TaskState | null = null; protected currentUiControls: TaskUIControls; protected uiControlConfig: UIControlConfig; @@ -129,6 +128,7 @@ export default abstract class Task extends EventEmitter implements ITask { LoggerProxy.log('unregisterWebCallListeners called', { module: CC_FILE, method: 'unregisterWebCallListeners', + interactionId: this.data?.interactionId, }); } @@ -179,34 +179,10 @@ export default abstract class Task extends EventEmitter implements ITask { } /** - * Initialize the state machine with custom action callbacks + * Initialize the state machine */ private initializeStateMachine(): void { - const callbacks: ActionCallbacks = { - onTaskIncoming: (taskData) => { - LoggerProxy.log('State machine: Task incoming', { - module: CC_FILE, - method: 'onTaskIncoming', - interactionId: taskData.interactionId, - }); - }, - onTaskAssigned: (taskData) => { - LoggerProxy.log('State machine: Task assigned', { - module: CC_FILE, - method: 'onTaskAssigned', - interactionId: taskData.interactionId, - }); - }, - onCleanupResources: () => {}, - }; - - // Create custom actions with callbacks for event emission - const eventActions = createActionsWithCallbacks(callbacks); - - const machine: TaskStateMachine = createTaskStateMachineWithActions( - this.uiControlConfig, - eventActions - ); + const machine: TaskStateMachine = createTaskStateMachine(this.uiControlConfig); this.stateMachineService = createActor(machine); @@ -293,6 +269,7 @@ export default abstract class Task extends EventEmitter implements ITask { LoggerProxy.error(`Unsupported operation`, { module: 'TASK', method: methodName, + interactionId: this.data?.interactionId, }); throw new Error(`Unsupported operation: ${methodName}`); } diff --git a/packages/@webex/contact-center/src/services/task/TaskManager.ts b/packages/@webex/contact-center/src/services/task/TaskManager.ts index 601264663d0..45bc0714e53 100644 --- a/packages/@webex/contact-center/src/services/task/TaskManager.ts +++ b/packages/@webex/contact-center/src/services/task/TaskManager.ts @@ -15,6 +15,17 @@ import TaskFactory from './TaskFactory'; import WebRTC from './voice/WebRTC'; import {TaskEvent, type TaskEventPayload} from './state-machine'; import {normalizeTaskData} from './taskDataNormalizer'; + +type WebSocketPayload = TaskData & { + type: CC_EVENTS | string; + mediaResourceId?: string; + reason?: string; +}; + +type WebSocketMessage = { + keepalive?: 'true' | 'false' | boolean; + data: WebSocketPayload; +}; /** @internal */ export default class TaskManager extends EventEmitter { private call: ICall; @@ -107,24 +118,24 @@ export default class TaskManager extends EventEmitter { * @param payload - The event payload * @returns TaskEventPayload for state machine or null if no mapping */ - private mapWebSocketEventToStateMachineEvent( + private static mapEventToTaskStateMachineEvent( ccEvent: CC_EVENTS, - payload: any + payload: WebSocketPayload ): TaskEventPayload | null { const mediaResourceId = - payload.data?.mediaResourceId || - payload.data?.interaction?.media?.[payload.data?.interactionId]?.mediaResourceId; + payload.mediaResourceId || + payload.interaction?.media?.[payload.interactionId]?.mediaResourceId; switch (ccEvent) { case CC_EVENTS.AGENT_CONTACT_RESERVED: case CC_EVENTS.AGENT_OFFER_CONTACT: - return {type: TaskEvent.OFFER, taskData: payload.data}; + return {type: TaskEvent.OFFER, taskData: payload}; case CC_EVENTS.AGENT_OFFER_CONSULT: - return {type: TaskEvent.OFFER_CONSULT, taskData: payload.data}; + return {type: TaskEvent.OFFER_CONSULT, taskData: payload}; case CC_EVENTS.AGENT_CONTACT_ASSIGNED: - return {type: TaskEvent.ASSIGN, taskData: payload.data}; + return {type: TaskEvent.ASSIGN, taskData: payload}; case CC_EVENTS.AGENT_CONTACT_HELD: return {type: TaskEvent.HOLD, mediaResourceId: mediaResourceId || ''}; @@ -133,7 +144,7 @@ export default class TaskManager extends EventEmitter { return {type: TaskEvent.UNHOLD, mediaResourceId: mediaResourceId || ''}; case CC_EVENTS.AGENT_CONSULT_CREATED: - return {type: TaskEvent.CONSULT_CREATED, taskData: payload.data}; + return {type: TaskEvent.CONSULT_CREATED, taskData: payload}; case CC_EVENTS.AGENT_CONSULTING: return { @@ -145,7 +156,7 @@ export default class TaskManager extends EventEmitter { return {type: TaskEvent.CONSULT_END}; case CC_EVENTS.AGENT_CONSULT_FAILED: - return {type: TaskEvent.CONSULT_FAILED, reason: payload.data?.reason}; + return {type: TaskEvent.CONSULT_FAILED, reason: payload.reason}; case CC_EVENTS.AGENT_CTQ_CANCELLED: return {type: TaskEvent.CTQ_CANCEL}; @@ -159,11 +170,14 @@ export default class TaskManager extends EventEmitter { return {type: TaskEvent.CONTACT_ENDED}; case CC_EVENTS.AGENT_INVITE_FAILED: - return {type: TaskEvent.INVITE_FAILED, reason: payload.data?.reason}; + return {type: TaskEvent.INVITE_FAILED, reason: payload.reason}; case CC_EVENTS.AGENT_CONTACT_OFFER_RONA: return {type: TaskEvent.RONA}; + case CC_EVENTS.CONTACT_RECORDING_STARTED: + return {type: TaskEvent.RECORDING_STARTED, taskData: payload}; + case CC_EVENTS.CONTACT_RECORDING_PAUSED: return {type: TaskEvent.PAUSE_RECORDING}; @@ -182,20 +196,24 @@ export default class TaskManager extends EventEmitter { * @param payload - The event payload * @param task - The task instance */ - private sendEventToStateMachine(ccEvent: CC_EVENTS, payload: any, task?: ITask): void { + private sendEventToStateMachine( + ccEvent: CC_EVENTS, + payload: WebSocketPayload, + task?: ITask + ): void { // Check if task has state machine (will be added in Task interface) const taskWithStateMachine = task as any; if (!taskWithStateMachine?.stateMachineService) { return; } - const stateMachineEvent = this.mapWebSocketEventToStateMachineEvent(ccEvent, payload); + const stateMachineEvent = TaskManager.mapEventToTaskStateMachineEvent(ccEvent, payload); if (stateMachineEvent) { LoggerProxy.log(`Sending event to state machine: ${ccEvent} -> ${stateMachineEvent.type}`, { module: TASK_MANAGER_FILE, method: 'sendEventToStateMachine', - interactionId: payload.data?.interactionId, + interactionId: payload.interactionId, }); // Send event to task's state machine @@ -205,7 +223,7 @@ export default class TaskManager extends EventEmitter { private registerTaskListeners() { this.webSocketManager.on('message', (event) => { - const payload = JSON.parse(event); + const payload = JSON.parse(event) as WebSocketMessage; if (payload?.keepalive === 'true' || payload?.keepalive === true) { return; } @@ -399,6 +417,10 @@ export default class TaskManager extends EventEmitter { this.removeTaskFromCollection(task); task.emit(TASK_EVENTS.TASK_WRAPPEDUP, task); break; + case CC_EVENTS.CONTACT_RECORDING_STARTED: + this.updateTaskData(task, payload.data); + task.emit(TASK_EVENTS.TASK_RECORDING_STARTED, task); + break; case CC_EVENTS.CONTACT_RECORDING_PAUSED: this.updateTaskData(task, payload.data); task.emit(TASK_EVENTS.TASK_RECORDING_PAUSED, task); @@ -433,7 +455,7 @@ export default class TaskManager extends EventEmitter { task.emit(payload.data.type, payload.data); // Send event to state machine for all events - this.sendEventToStateMachine(payload.data.type, payload, task); + this.sendEventToStateMachine(payload.data.type as CC_EVENTS, payload.data, task); } } }); diff --git a/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts b/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts index ef5c9c10087..efb3a88eac7 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts @@ -21,6 +21,11 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { id: 'taskStateMachine', initial: TaskState.IDLE, context: createInitialContext(uiControlConfig, TaskState.IDLE), + on: { + [TaskEvent.RECORDING_STARTED]: { + actions: ['updateTaskData'], + }, + }, states: { [TaskState.IDLE]: { on: { @@ -243,7 +248,9 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { } /** - * Create a task state machine instance + * Create a task state machine instance using only the built-in actions. + * The resulting machine is ready for most consumers that rely on the default + * context mutators declared in actions.ts. * * @param uiControlConfig - UI control configuration * @returns StateMachine instance for task management @@ -254,24 +261,4 @@ export function createTaskStateMachine(uiControlConfig: UIControlConfig) { }); } -/** - * Create a task state machine with custom actions - * This allows the Task/Voice class to inject their own event emission and side effects - * - * @param uiControlConfig - UI control configuration - * @param customActions - Custom action implementations - * @returns StateMachine instance with custom actions - */ -export function createTaskStateMachineWithActions( - uiControlConfig: UIControlConfig, - customActions: Record -) { - return createMachine(getTaskStateMachineConfig(uiControlConfig), { - actions: { - ...actions, - ...customActions, - }, - }); -} - export type TaskStateMachine = ReturnType; diff --git a/packages/@webex/contact-center/src/services/task/state-machine/actions.ts b/packages/@webex/contact-center/src/services/task/state-machine/actions.ts index d75749fd38c..3e684559d23 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/actions.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/actions.ts @@ -12,19 +12,76 @@ */ import {assign} from 'xstate'; -import { - TaskContext, - TaskEventPayload, - isEventOfType, - TaskEvent, - UIControlConfig, - TaskState, -} from './types'; +import {TaskContext, TaskEventPayload, TaskEvent, UIControlConfig, TaskState} from './types'; import {TaskData} from '../types'; import {computeUIControls, getDefaultUIControls} from './uiControlsComputer'; +type RecordingStateUpdate = Partial< + Pick +>; + +const deriveRecordingState = (taskData?: TaskData | null): RecordingStateUpdate => { + const callProcessingDetails = taskData?.interaction?.callProcessingDetails; + + if (!callProcessingDetails) { + return {}; + } + + const update: RecordingStateUpdate = {}; + const {recordingStarted, recordInProgress} = callProcessingDetails; + + if (recordingStarted !== undefined) { + update.recordingControlsAvailable = recordingStarted; + if (!recordingStarted) { + update.recordingInProgress = false; + } + } + + if (recordInProgress !== undefined) { + update.recordingControlsAvailable = recordInProgress || recordingStarted || false; + update.recordingInProgress = recordInProgress; + } + + if ( + update.recordingControlsAvailable === undefined && + update.recordingInProgress === undefined && + recordingStarted + ) { + update.recordingControlsAvailable = true; + update.recordingInProgress = true; + } + + return update; +}; + +/** + * Copy latest backend payload into context. + * + * We intentionally replace the entire taskData reference instead of + * merging individual fields so that the context always mirrors the + * most recent socket payload (offer, assign, consult, recording, etc.). + * Every downstream consumer can therefore rely on taskData being the + * single source of truth, while derived values (like recording flags) + * are recalculated here via deriveRecordingState. + */ +const deriveTaskDataUpdates = (_context: TaskContext, taskData: TaskData) => ({ + taskData, + ...deriveRecordingState(taskData), +}); + /** - * Create initial context for a new task + * Create initial context for a new task. + * + * Only include data here that CANNOT be derived from the state value itself. + * Examples: + * - Latest backend payload (`taskData`) so actions/guards can read raw fields. + * - Flags that track who initiated the consult, destination info, or recording + * availability – these depend on payloads, not just the state enum. + * - The immutable UI control configuration and the last computed UI controls. + * + * Avoid storing duplicates of the current state (e.g. `isHeld`, `isConnected`), + * because the state node already encodes that truth. Treat this context shape as + * the contract new states/actions should follow when they need extra data. * * @param uiControlConfig - UI control configuration * @param initialState - Initial state for computing UI controls @@ -73,25 +130,20 @@ export const actions = { * Initialize task with offer data */ initializeTask: assign((context: TaskContext, event: TaskEventPayload) => { - if (isEventOfType(event, TaskEvent.OFFER) || isEventOfType(event, TaskEvent.OFFER_CONSULT)) { - return deriveTaskDataUpdates(context, event.taskData); - } + // Guard not needed in this action because the state machine only references + // initializeTask from OFFER/OFFER_CONSULT transitions, both of which carry taskData. + const {taskData} = event as Extract; - return {}; + return deriveTaskDataUpdates(context, taskData); }), /** * Update task data from ASSIGN event */ updateTaskData: assign((context: TaskContext, event: TaskEventPayload) => { - if (isEventOfType(event, TaskEvent.ASSIGN)) { - return deriveTaskDataUpdates(context, event.taskData); - } - if (isEventOfType(event, TaskEvent.CONSULT_CREATED)) { - return deriveTaskDataUpdates(context, event.taskData); - } + const {taskData} = event as Extract; - return {}; + return deriveTaskDataUpdates(context, taskData); }), /** @@ -105,39 +157,41 @@ export const actions = { * Set consult destination details */ setConsultDestination: assign((context: TaskContext, event: TaskEventPayload) => { - if (isEventOfType(event, TaskEvent.CONSULT)) { - return { - consultDestination: event.destination, - }; - } - - return {}; + const consultEvent = event as Extract< + TaskEventPayload, + {type: TaskEvent.CONSULT; destination: string} + >; + + return { + consultDestination: consultEvent.destination, + }; }), /** * Mark that consult destination agent has joined */ setConsultAgentJoined: assign((context: TaskContext, event: TaskEventPayload) => { - if (isEventOfType(event, TaskEvent.CONSULTING_ACTIVE)) { - return { - consultDestinationAgentJoined: event.consultDestinationAgentJoined, - }; - } - - return {}; + const consultingActive = event as Extract< + TaskEventPayload, + {type: TaskEvent.CONSULTING_ACTIVE; consultDestinationAgentJoined: boolean} + >; + + return { + consultDestinationAgentJoined: consultingActive.consultDestinationAgentJoined, + }; }), /** * Set recording state */ setRecordingState: assign((context: TaskContext, event: TaskEventPayload) => { - if (isEventOfType(event, TaskEvent.PAUSE_RECORDING)) { + if (event.type === TaskEvent.PAUSE_RECORDING) { return { recordingControlsAvailable: true, recordingInProgress: false, }; } - if (isEventOfType(event, TaskEvent.RESUME_RECORDING)) { + if (event.type === TaskEvent.RESUME_RECORDING) { return { recordingControlsAvailable: true, recordingInProgress: true, @@ -159,38 +213,36 @@ export const actions = { * Track hold state updates (currently no-op placeholder) */ setHoldState: assign((context: TaskContext, event: TaskEventPayload) => { - if ( - isEventOfType(event, TaskEvent.HOLD_SUCCESS) || - isEventOfType(event, TaskEvent.UNHOLD_SUCCESS) - ) { - const mediaResourceId = event.mediaResourceId; - const interaction = context.taskData?.interaction; - const mediaEntry = interaction?.media?.[mediaResourceId]; - - if (!interaction || !mediaEntry) { - return {}; - } - - const updatedMedia = { - ...interaction.media, - [mediaResourceId]: { - ...mediaEntry, - isHold: isEventOfType(event, TaskEvent.HOLD_SUCCESS), - }, - }; - - return { - taskData: { - ...(context.taskData as TaskData), - interaction: { - ...interaction, - media: updatedMedia, - }, - }, - }; + const holdEvent = event as Extract< + TaskEventPayload, + | {type: TaskEvent.HOLD_SUCCESS; mediaResourceId: string} + | {type: TaskEvent.UNHOLD_SUCCESS; mediaResourceId: string} + >; + const mediaResourceId = holdEvent.mediaResourceId; + const interaction = context.taskData?.interaction; + const mediaEntry = interaction?.media?.[mediaResourceId]; + + if (!interaction || !mediaEntry) { + return {}; } - return {}; + const updatedMedia = { + ...interaction.media, + [mediaResourceId]: { + ...mediaEntry, + isHold: holdEvent.type === TaskEvent.HOLD_SUCCESS, + }, + }; + + return { + taskData: { + ...(context.taskData as TaskData), + interaction: { + ...interaction, + media: updatedMedia, + }, + }, + }; }), /** @@ -209,105 +261,31 @@ export const actions = { }, }; -type RecordingStateUpdate = Partial< - Pick ->; - -const deriveRecordingState = (taskData?: TaskData | null): RecordingStateUpdate => { - const callProcessingDetails = taskData?.interaction?.callProcessingDetails; - - if (!callProcessingDetails) { - return {}; - } - - const update: RecordingStateUpdate = {}; - const {recordingStarted, recordInProgress} = callProcessingDetails; - - if (recordingStarted !== undefined) { - update.recordingControlsAvailable = recordingStarted; - if (!recordingStarted) { - update.recordingInProgress = false; - } - } - - if (recordInProgress !== undefined) { - update.recordingControlsAvailable = recordInProgress || recordingStarted || false; - update.recordingInProgress = recordInProgress; - } - - if ( - update.recordingControlsAvailable === undefined && - update.recordingInProgress === undefined && - recordingStarted - ) { - update.recordingControlsAvailable = true; - update.recordingInProgress = true; - } - - return update; -}; - -const deriveTaskDataUpdates = (_context: TaskContext, taskData: TaskData) => ({ - taskData, - ...deriveRecordingState(taskData), -}); - /** - * Helper to create action implementations that will be used by Task/Voice classes - * These factories allow the Task/Voice class to inject their own logic while keeping - * the state machine pure and testable. - */ -export interface ActionCallbacks { - onTaskIncoming?: (taskData: any) => void; - onTaskAssigned?: (taskData: any) => void; - onTaskHold?: (taskData: any) => void; - onTaskResume?: (taskData: any) => void; - onTaskConsultCreated?: (taskData: any) => void; - onTaskConsulting?: (taskData: any) => void; - onTaskConsultEnd?: (taskData: any) => void; - onTaskEnd?: (taskData: any) => void; - onTaskWrappedup?: (taskData: any) => void; - onCleanupResources?: () => void; -} - -/** - * Create action implementations with callbacks - * This allows the Task/Voice class to provide implementation for side effects + * NOTE FOR FUTURE ACTION HOOKS: + * Once we emit Task events from the state machine instead of `TaskManager`, + * provide custom actions when creating the machine (e.g. wrap + * `createTaskStateMachineConfig` yourself). For example: + * + * ```ts + * const customActions = { + * emitTaskAssigned: (context: TaskContext) => { + * task.emit(TASK_EVENTS.TASK_ASSIGNED, { + * interactionId: context.taskData?.interactionId, + * taskData: context.taskData, + * }); + * }, + * }; + * + * const machine = createMachine(getTaskStateMachineConfig(config), { + * actions: {...actions, ...customActions}, + * }); + * ``` + * + * Only add such callbacks when the event payload has to be derived from the + * latest state-machine context (e.g. wrap-up metadata, derived flags, etc.). + * If the payload is ready as soon as the websocket message arrives, continue + * emitting from `TaskManager` to avoid duplicating work inside the machine. + * Keeping the hooks outside this file ensures the core actions stay pure while + * still making it obvious where to place future side effects. */ -export function createActionsWithCallbacks(callbacks: ActionCallbacks) { - return { - // Event emission actions - emitTaskIncoming: (context: TaskContext) => { - callbacks.onTaskIncoming?.(context.taskData); - }, - emitTaskAssigned: (context: TaskContext) => { - callbacks.onTaskAssigned?.(context.taskData); - }, - emitTaskHold: (context: TaskContext) => { - callbacks.onTaskHold?.(context.taskData); - }, - emitTaskResume: (context: TaskContext) => { - callbacks.onTaskResume?.(context.taskData); - }, - emitTaskConsultCreated: (context: TaskContext) => { - callbacks.onTaskConsultCreated?.(context.taskData); - }, - emitTaskConsulting: (context: TaskContext) => { - callbacks.onTaskConsulting?.(context.taskData); - }, - emitTaskConsultEnd: (context: TaskContext) => { - callbacks.onTaskConsultEnd?.(context.taskData); - }, - emitTaskEnd: (context: TaskContext) => { - callbacks.onTaskEnd?.(context.taskData); - }, - emitTaskWrappedup: (context: TaskContext) => { - callbacks.onTaskWrappedup?.(context.taskData); - }, - - // Cleanup action - cleanupResources: () => { - callbacks.onCleanupResources?.(); - }, - }; -} diff --git a/packages/@webex/contact-center/src/services/task/state-machine/index.ts b/packages/@webex/contact-center/src/services/task/state-machine/index.ts index 61d07f6c5f0..dc8563d78ad 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/index.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/index.ts @@ -5,11 +5,7 @@ */ // Main state machine -export { - getTaskStateMachineConfig, - createTaskStateMachine, - createTaskStateMachineWithActions, -} from './TaskStateMachine'; +export {getTaskStateMachineConfig, createTaskStateMachine} from './TaskStateMachine'; export type {TaskStateMachine} from './TaskStateMachine'; // Types @@ -28,5 +24,4 @@ export type {GuardParams, GuardFunction} from './guards'; export type {TaskAction} from './types'; // Actions -export {actions, createInitialContext, createActionsWithCallbacks} from './actions'; -export type {ActionCallbacks} from './actions'; +export {actions, createInitialContext} from './actions'; diff --git a/packages/@webex/contact-center/src/services/task/state-machine/types.ts b/packages/@webex/contact-center/src/services/task/state-machine/types.ts index 504c3769dae..9ca1746d137 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/types.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/types.ts @@ -81,6 +81,7 @@ export enum TaskEvent { EXIT_CONFERENCE = 'EXIT_CONFERENCE', // Recording events + RECORDING_STARTED = 'RECORDING_STARTED', PAUSE_RECORDING = 'PAUSE_RECORDING', RESUME_RECORDING = 'RESUME_RECORDING', @@ -211,6 +212,7 @@ export type TaskEventPayload = | {type: TaskEvent.PARTICIPANT_JOIN; participant: ConferenceParticipant} | {type: TaskEvent.PARTICIPANT_LEAVE; participantId: string} | {type: TaskEvent.EXIT_CONFERENCE; agentId?: string} + | {type: TaskEvent.RECORDING_STARTED; taskData: TaskData} | {type: TaskEvent.PAUSE_RECORDING} | {type: TaskEvent.RESUME_RECORDING} | {type: TaskEvent.TRANSFER} diff --git a/packages/@webex/contact-center/src/services/task/types.ts b/packages/@webex/contact-center/src/services/task/types.ts index 1f2a80c7a66..99c350fff36 100644 --- a/packages/@webex/contact-center/src/services/task/types.ts +++ b/packages/@webex/contact-center/src/services/task/types.ts @@ -297,6 +297,17 @@ export enum TASK_EVENTS { */ TASK_WRAPPEDUP = 'task:wrappedup', + /** + * Triggered when recording is started + * @example + * ```typescript + * task.on(TASK_EVENTS.TASK_RECORDING_STARTED, (task: ITask) => { + * console.log('Recording started:', task.data.interactionId); + * }); + * ``` + */ + TASK_RECORDING_STARTED = 'task:recordingStarted', + /** * Triggered when recording is paused * @example diff --git a/packages/@webex/contact-center/src/services/task/voice/Voice.ts b/packages/@webex/contact-center/src/services/task/voice/Voice.ts index b7bce36eab6..c7ffb15f194 100644 --- a/packages/@webex/contact-center/src/services/task/voice/Voice.ts +++ b/packages/@webex/contact-center/src/services/task/voice/Voice.ts @@ -180,7 +180,7 @@ export default class Voice extends Task implements IVoice { interactionId: this.data.interactionId, }); } else { - const mainId = this.data.interaction.mainInteractionId!; + const mainId = this.data.interaction?.mainInteractionId; response = await this.contact.unHold({ interactionId: this.data.interactionId, data: {mediaResourceId: this.data.mediaResourceId}, @@ -214,15 +214,12 @@ export default class Voice extends Task implements IVoice { return response; } catch (error) { - // Send failure event to transition back to previous state - if (this.stateMachineService) { - const failureEvent = shouldHold ? TaskEvent.HOLD_FAILED : TaskEvent.UNHOLD_FAILED; - this.stateMachineService.send({ - type: failureEvent, - reason: error.toString(), - mediaResourceId: this.data.mediaResourceId, - }); - } + const failureEvent = shouldHold ? TaskEvent.HOLD_FAILED : TaskEvent.UNHOLD_FAILED; + this.stateMachineService.send({ + type: failureEvent, + reason: error.toString(), + mediaResourceId: this.data.mediaResourceId, + }); const {error: detailedError} = getErrorDetails(error, 'holdResume', CC_FILE); this.metricsManager.trackEvent( @@ -463,13 +460,10 @@ export default class Voice extends Task implements IVoice { return result; } catch (error) { - // Send failure event to transition back to previous state - if (this.stateMachineService) { - this.stateMachineService.send({ - type: TaskEvent.CONSULT_FAILED, - reason: error.toString(), - }); - } + this.stateMachineService.send({ + type: TaskEvent.CONSULT_FAILED, + reason: error.toString(), + }); const {error: detailedError} = getErrorDetails(error, 'consult', CC_FILE); this.metricsManager.trackEvent( From 0627663f10ba84b50d792a1f66670890ea97c3d3 Mon Sep 17 00:00:00 2001 From: arungane Date: Mon, 1 Dec 2025 11:39:47 +0530 Subject: [PATCH 11/14] test: update the unit for contact center --- .../src/services/task/TaskManager.ts | 26 +- .../task/state-machine/TaskStateMachine.ts | 26 +- .../services/task/state-machine/actions.ts | 113 +++-- .../contact-center/test/unit/spec/cc.ts | 85 +--- .../test/unit/spec/services/task/Task.ts | 104 +++-- .../unit/spec/services/task/TaskManager.ts | 435 +++-------------- .../spec/services/task/digital/Digital.ts | 135 +++--- .../task/state-machine/TaskStateMachine.ts | 241 ++++++++++ .../unit/spec/services/task/taskTestUtils.ts | 87 ++++ .../unit/spec/services/task/voice/Voice.ts | 436 ++++++------------ .../unit/spec/services/task/voice/WebRTC.ts | 162 ++++--- packages/@webex/webex-core/src/index.js | 11 +- .../webex-core/src/lib/services-v2/index.js | 1 + 13 files changed, 894 insertions(+), 968 deletions(-) create mode 100644 packages/@webex/contact-center/test/unit/spec/services/task/state-machine/TaskStateMachine.ts create mode 100644 packages/@webex/contact-center/test/unit/spec/services/task/taskTestUtils.ts diff --git a/packages/@webex/contact-center/src/services/task/TaskManager.ts b/packages/@webex/contact-center/src/services/task/TaskManager.ts index 45bc0714e53..d68fd5dcb46 100644 --- a/packages/@webex/contact-center/src/services/task/TaskManager.ts +++ b/packages/@webex/contact-center/src/services/task/TaskManager.ts @@ -26,6 +26,10 @@ type WebSocketMessage = { keepalive?: 'true' | 'false' | boolean; data: WebSocketPayload; }; + +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); /** @internal */ export default class TaskManager extends EventEmitter { private call: ICall; @@ -196,7 +200,7 @@ export default class TaskManager extends EventEmitter { * @param payload - The event payload * @param task - The task instance */ - private sendEventToStateMachine( + private static sendEventToStateMachine( ccEvent: CC_EVENTS, payload: WebSocketPayload, task?: ITask @@ -232,16 +236,17 @@ export default class TaskManager extends EventEmitter { } // Re-emit the task events to the task object let task: ITask; - if (payload.data?.type) { - if (Object.values(CC_TASK_EVENTS).includes(payload.data.type)) { - task = this.taskCollection[payload.data.interactionId]; - } - LoggerProxy.info(`Handling task event ${payload.data?.type}`, { + const eventType = payload.data?.type; + + if (eventType && isCcEvent(eventType)) { + task = this.taskCollection[payload.data.interactionId]; + + LoggerProxy.info(`Handling task event ${eventType}`, { module: TASK_MANAGER_FILE, method: METHODS.REGISTER_TASK_LISTENERS, interactionId: payload.data?.interactionId, }); - switch (payload.data.type) { + switch (eventType) { case CC_EVENTS.AGENT_CONTACT: if (!task) { // Re-create task if it does not exist @@ -452,10 +457,13 @@ export default class TaskManager extends EventEmitter { // Send all events to state machine after processing // Task may have been created in AGENT_CONTACT or AGENT_CONTACT_RESERVED cases if (task) { - task.emit(payload.data.type, payload.data); + // Only emit task-specific events to the task object + if (Object.values(CC_TASK_EVENTS).includes(eventType as any)) { + task.emit(eventType as any, payload.data); + } // Send event to state machine for all events - this.sendEventToStateMachine(payload.data.type as CC_EVENTS, payload.data, task); + TaskManager.sendEventToStateMachine(eventType, payload.data, task); } } }); diff --git a/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts b/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts index efb3a88eac7..eea1e197286 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts @@ -5,10 +5,26 @@ * It orchestrates state transitions, guards, and actions for task lifecycle management. */ -import {createMachine} from 'xstate'; -import {TaskState, TaskEvent, UIControlConfig} from './types'; +import {setup} from 'xstate'; +import {TaskState, TaskEvent, TaskContext, TaskEventPayload, UIControlConfig} from './types'; import {actions, createInitialContext} from './actions'; +type TaskActionConfigMap = {[K in keyof typeof actions]: undefined}; + +const taskStateMachineSetup = setup< + TaskContext, + TaskEventPayload, + Record, + Record, + TaskActionConfigMap +>({ + types: { + context: {} as TaskContext, + events: {} as TaskEventPayload, + }, + actors: {}, +}); + /** * Get task state machine configuration with UI control config * Defines all states, transitions, guards, and actions for task management @@ -256,9 +272,9 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { * @returns StateMachine instance for task management */ export function createTaskStateMachine(uiControlConfig: UIControlConfig) { - return createMachine(getTaskStateMachineConfig(uiControlConfig), { - actions, - }); + return taskStateMachineSetup + .createMachine(getTaskStateMachineConfig(uiControlConfig)) + .provide({actions}); } export type TaskStateMachine = ReturnType; diff --git a/packages/@webex/contact-center/src/services/task/state-machine/actions.ts b/packages/@webex/contact-center/src/services/task/state-machine/actions.ts index 3e684559d23..9cad8592369 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/actions.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/actions.ts @@ -12,10 +12,21 @@ */ import {assign} from 'xstate'; +import type {ActionFunctionMap, EventObject} from 'xstate'; import {TaskContext, TaskEventPayload, TaskEvent, UIControlConfig, TaskState} from './types'; import {TaskData} from '../types'; import {computeUIControls, getDefaultUIControls} from './uiControlsComputer'; +type TaskActionsMap = ActionFunctionMap< + TaskContext, + TaskEventPayload, + never, + {type: string; params: undefined}, + never, + never, + EventObject +>; + type RecordingStateUpdate = Partial< Pick >; @@ -28,7 +39,11 @@ const deriveRecordingState = (taskData?: TaskData | null): RecordingStateUpdate } const update: RecordingStateUpdate = {}; - const {recordingStarted, recordInProgress} = callProcessingDetails; + const {recordingStarted, recordInProgress, isPaused} = callProcessingDetails as { + recordingStarted?: boolean; + recordInProgress?: boolean; + isPaused?: boolean; + }; if (recordingStarted !== undefined) { update.recordingControlsAvailable = recordingStarted; @@ -51,6 +66,11 @@ const deriveRecordingState = (taskData?: TaskData | null): RecordingStateUpdate update.recordingInProgress = true; } + if (isPaused !== undefined) { + update.recordingControlsAvailable = true; + update.recordingInProgress = !isPaused; + } + return update; }; @@ -64,10 +84,16 @@ const deriveRecordingState = (taskData?: TaskData | null): RecordingStateUpdate * single source of truth, while derived values (like recording flags) * are recalculated here via deriveRecordingState. */ -const deriveTaskDataUpdates = (_context: TaskContext, taskData: TaskData) => ({ - taskData, - ...deriveRecordingState(taskData), -}); +const deriveTaskDataUpdates = (_context: TaskContext, taskData: TaskData | undefined) => + taskData + ? { + taskData, + ...deriveRecordingState(taskData), + } + : {}; + +const getTaskDataFromEvent = (event?: TaskEventPayload): TaskData | undefined => + event && typeof event === 'object' ? (event as any).taskData : undefined; /** * Create initial context for a new task. @@ -116,7 +142,7 @@ export function createInitialContext( * @returns Assign action that updates UI controls */ export function updateUIControls(currentState: TaskState) { - return assign((context: TaskContext) => ({ + return assign(({context}: {context: TaskContext}) => ({ uiControls: computeUIControls(currentState, context), })); } @@ -125,25 +151,19 @@ export function updateUIControls(currentState: TaskState) { * Action implementations * These return XState assign actions that update the context */ -export const actions = { +export const actions: TaskActionsMap = { /** * Initialize task with offer data */ - initializeTask: assign((context: TaskContext, event: TaskEventPayload) => { - // Guard not needed in this action because the state machine only references - // initializeTask from OFFER/OFFER_CONSULT transitions, both of which carry taskData. - const {taskData} = event as Extract; - - return deriveTaskDataUpdates(context, taskData); + initializeTask: assign(({context, event}: {context: TaskContext; event: TaskEventPayload}) => { + return deriveTaskDataUpdates(context, getTaskDataFromEvent(event)); }), /** * Update task data from ASSIGN event */ - updateTaskData: assign((context: TaskContext, event: TaskEventPayload) => { - const {taskData} = event as Extract; - - return deriveTaskDataUpdates(context, taskData); + updateTaskData: assign(({context, event}: {context: TaskContext; event: TaskEventPayload}) => { + return deriveTaskDataUpdates(context, getTaskDataFromEvent(event)); }), /** @@ -156,35 +176,42 @@ export const actions = { /** * Set consult destination details */ - setConsultDestination: assign((context: TaskContext, event: TaskEventPayload) => { - const consultEvent = event as Extract< - TaskEventPayload, - {type: TaskEvent.CONSULT; destination: string} - >; + setConsultDestination: assign(({event}: {event: TaskEventPayload}) => { + if (!event || event.type !== TaskEvent.CONSULT || !('destination' in event)) { + return {}; + } return { - consultDestination: consultEvent.destination, + consultDestination: (event as {destination: string}).destination, }; }), /** * Mark that consult destination agent has joined */ - setConsultAgentJoined: assign((context: TaskContext, event: TaskEventPayload) => { - const consultingActive = event as Extract< - TaskEventPayload, - {type: TaskEvent.CONSULTING_ACTIVE; consultDestinationAgentJoined: boolean} - >; + setConsultAgentJoined: assign(({event}: {event: TaskEventPayload}) => { + if ( + !event || + event.type !== TaskEvent.CONSULTING_ACTIVE || + !('consultDestinationAgentJoined' in event) + ) { + return {}; + } return { - consultDestinationAgentJoined: consultingActive.consultDestinationAgentJoined, + consultDestinationAgentJoined: (event as {consultDestinationAgentJoined: boolean}) + .consultDestinationAgentJoined, }; }), /** * Set recording state */ - setRecordingState: assign((context: TaskContext, event: TaskEventPayload) => { + setRecordingState: assign(({event}: {event: TaskEventPayload}) => { + if (!event || !('type' in event)) { + return {}; + } + if (event.type === TaskEvent.PAUSE_RECORDING) { return { recordingControlsAvailable: true, @@ -212,13 +239,23 @@ export const actions = { /** * Track hold state updates (currently no-op placeholder) */ - setHoldState: assign((context: TaskContext, event: TaskEventPayload) => { - const holdEvent = event as Extract< - TaskEventPayload, - | {type: TaskEvent.HOLD_SUCCESS; mediaResourceId: string} - | {type: TaskEvent.UNHOLD_SUCCESS; mediaResourceId: string} - >; - const mediaResourceId = holdEvent.mediaResourceId; + setHoldState: assign(({context, event}: {context: TaskContext; event: TaskEventPayload}) => { + if ( + !event || + (event.type !== TaskEvent.HOLD_SUCCESS && event.type !== TaskEvent.UNHOLD_SUCCESS) + ) { + return {}; + } + + const mediaResourceId = + 'mediaResourceId' in event + ? (event as {mediaResourceId?: string}).mediaResourceId + : undefined; + + if (!mediaResourceId) { + return {}; + } + const interaction = context.taskData?.interaction; const mediaEntry = interaction?.media?.[mediaResourceId]; @@ -230,7 +267,7 @@ export const actions = { ...interaction.media, [mediaResourceId]: { ...mediaEntry, - isHold: holdEvent.type === TaskEvent.HOLD_SUCCESS, + isHold: event.type === TaskEvent.HOLD_SUCCESS, }, }; diff --git a/packages/@webex/contact-center/test/unit/spec/cc.ts b/packages/@webex/contact-center/test/unit/spec/cc.ts index df0bfcb94ae..8a295e18f6d 100644 --- a/packages/@webex/contact-center/test/unit/spec/cc.ts +++ b/packages/@webex/contact-center/test/unit/spec/cc.ts @@ -1452,84 +1452,23 @@ describe('webex.cc', () => { }); describe('getQueues', () => { - it('should return queues response when successful', async () => { - const mockQueuesResponse = [ - { - queueId: 'queue1', - queueName: 'Queue 1', - }, - { - queueId: 'queue2', - queueName: 'Queue 2', - }, - ]; - - webex.cc.services.config.getQueues = jest.fn().mockResolvedValue(mockQueuesResponse); - - const result = await webex.cc.getQueues(); - - // Verify logging calls - expect(LoggerProxy.info).toHaveBeenCalledWith('Fetching queues', { - module: CC_FILE, - method: 'getQueues', - }); - expect(LoggerProxy.log).toHaveBeenCalledWith( - `Successfully retrieved ${result.length} queues`, - { - module: CC_FILE, - method: 'getQueues', - } - ); + it('delegates to the queue service when successful', async () => { + const mockQueuesResponse = [{queueId: 'queue1', queueName: 'Queue 1'}]; + const queueSpy = jest + .spyOn(webex.cc.queue, 'getQueues') + .mockResolvedValue(mockQueuesResponse as any); - expect(webex.cc.services.config.getQueues).toHaveBeenCalledWith( - 'mockOrgId', - 0, - 100, - undefined, - undefined - ); - expect(result).toEqual(mockQueuesResponse); - }); - - it('should throw an error if orgId is not present', async () => { - jest.spyOn(webex.credentials, 'getOrgId').mockResolvedValue(undefined); - webex.cc.services.config.getQueues = jest.fn(); + const result = await webex.cc.getQueues({page: 1}); - try { - await webex.cc.getQueues(); - } catch (error) { - expect(error).toEqual(new Error('Org ID not found.')); - expect(LoggerProxy.info).toHaveBeenCalledWith('Fetching queues', { - module: CC_FILE, - method: 'getQueues', - }); - expect(LoggerProxy.error).toHaveBeenCalledWith('Org ID not found.', { - module: CC_FILE, - method: 'getQueues', - }); - expect(webex.cc.services.config.getQueues).not.toHaveBeenCalled(); - } + expect(queueSpy).toHaveBeenCalledWith({page: 1}); + expect(result).toBe(mockQueuesResponse); }); - it('should throw an error if config getQueues throws an error', async () => { - webex.cc.services.config.getQueues = jest.fn().mockRejectedValue(new Error('Test error.')); + it('propagates queue service errors', async () => { + const error = new Error('Test error.'); + jest.spyOn(webex.cc.queue, 'getQueues').mockRejectedValue(error); - try { - await webex.cc.getQueues(); - } catch (error) { - expect(error).toEqual(new Error('Test error.')); - expect(LoggerProxy.info).toHaveBeenCalledWith('Fetching queues', { - module: CC_FILE, - method: 'getQueues', - }); - expect(webex.cc.services.config.getQueues).toHaveBeenCalledWith( - 'mockOrgId', - 0, - 100, - undefined, - undefined - ); - } + await expect(webex.cc.getQueues()).rejects.toThrow('Test error.'); }); }); diff --git a/packages/@webex/contact-center/test/unit/spec/services/task/Task.ts b/packages/@webex/contact-center/test/unit/spec/services/task/Task.ts index 46136d8fb68..fd459d41bb3 100644 --- a/packages/@webex/contact-center/test/unit/spec/services/task/Task.ts +++ b/packages/@webex/contact-center/test/unit/spec/services/task/Task.ts @@ -1,8 +1,21 @@ import Task from '../../../../../src/services/task/Task'; import {TaskData, DESTINATION_TYPE} from '../../../../../src/services/task/types'; +import {TaskEvent} from '../../../../../src/services/task/state-machine'; +import LoggerProxy from '../../../../../src/logger-proxy'; +import {createTaskData} from './taskTestUtils'; class DummyTask extends Task { - public accept() { return Promise.resolve({} as any); } + constructor(contact: any, data: TaskData) { + super(contact, data, { + channelType: 'voice', + isEndCallEnabled: true, + isEndConsultEnabled: true, + }); + } + + public accept() { + return Promise.resolve({} as any); + } } jest.mock('../../../../../src/logger-proxy', () => ({ @@ -50,41 +63,66 @@ describe('Task (base class)', () => { expect((task.data as any).foo).toBeUndefined(); }); - it('getUIControls returns default controls shape', () => { - const controls = task.taskUiControls; - // all controls should be hidden/disabled - expect(controls.accept.visible).toBe(false); - expect(controls.accept.enabled).toBe(false); - expect(controls.decline.visible).toBe(false); - expect(controls.decline.enabled).toBe(false); - expect(controls.end.visible).toBe(false); - expect(controls.end.enabled).toBe(false); - expect(controls.transfer.visible).toBe(false); - expect(controls.transfer.enabled).toBe(false); - expect(controls.hold.visible).toBe(false); - expect(controls.hold.enabled).toBe(false); - expect(controls.mute.visible).toBe(false); - expect(controls.mute.enabled).toBe(false); - expect(controls.consult.visible).toBe(false); - expect(controls.consult.enabled).toBe(false); - expect(controls.consultTransfer.visible).toBe(false); - expect(controls.consultTransfer.enabled).toBe(false); - expect(controls.endConsult.visible).toBe(false); - expect(controls.endConsult.enabled).toBe(false); - expect(controls.recording.visible).toBe(false); - expect(controls.recording.enabled).toBe(false); - expect(controls.conference.visible).toBe(false); - expect(controls.conference.enabled).toBe(false); - expect(controls.wrapup.visible).toBe(false); - expect(controls.wrapup.enabled).toBe(false); - }); - - it('calls setUIControls when updateTaskData is invoked', () => { - const spy = jest.spyOn(task as any, 'setUIControls'); + it('getUIControls returns default controls shape for idle voice task', () => { + const controls = task.uiControls; + // accept/decline hidden because not offered + expect(controls.accept.isVisible).toBe(false); + expect(controls.accept.isEnabled).toBe(true); + expect(controls.decline.isVisible).toBe(false); + expect(controls.decline.isEnabled).toBe(true); + + // voice tasks always render end when enabled in config + expect(controls.end.isVisible).toBe(true); + expect(controls.end.isEnabled).toBe(true); + + expect(controls.transfer.isVisible).toBe(false); + expect(controls.transfer.isEnabled).toBe(true); + expect(controls.hold.isVisible).toBe(false); + expect(controls.hold.isEnabled).toBe(false); + expect(controls.mute.isVisible).toBe(false); + expect(controls.mute.isEnabled).toBe(true); + expect(controls.consult.isVisible).toBe(false); + expect(controls.consult.isEnabled).toBe(false); + expect(controls.consultTransfer.isVisible).toBe(false); + expect(controls.consultTransfer.isEnabled).toBe(true); + expect(controls.endConsult.isVisible).toBe(false); + expect(controls.endConsult.isEnabled).toBe(true); + expect(controls.recording.isVisible).toBe(false); + expect(controls.recording.isEnabled).toBe(false); + expect(controls.conference.isVisible).toBe(false); + expect(controls.conference.isEnabled).toBe(false); + expect(controls.wrapup.isVisible).toBe(false); + expect(controls.wrapup.isEnabled).toBe(true); + }); + + it('calls updateUiControls when updateTaskData is invoked', () => { + const spy = jest.spyOn(task as any, 'updateUiControls'); task.updateTaskData({foo: 'new'} as TaskData); expect(spy).toHaveBeenCalled(); }); + it('logs state transitions using locally tracked previous state', () => { + const logSpy = jest.spyOn(LoggerProxy, 'log'); + const statefulData = createTaskData(); + const transitionTask = new DummyTask(dummyContact, statefulData); + + logSpy.mockClear(); + + transitionTask.stateMachineService?.send({type: TaskEvent.OFFER, taskData: statefulData}); + transitionTask.stateMachineService?.send({type: TaskEvent.ACCEPT}); + + const transitionMessages = logSpy.mock.calls + .filter(([msg]) => typeof msg === 'string' && (msg as string).startsWith('State machine transition')) + .map(([msg]) => msg); + + expect(transitionMessages).toEqual([ + 'State machine transition: IDLE -> OFFERED', + 'State machine transition: OFFERED -> CONNECTED', + ]); + + transitionTask.stateMachineService?.stop(); + }); + }); describe('Task common methods', () => { @@ -198,4 +236,4 @@ describe('Task failure scenarios', () => { await expect(task.wrapup(payload)).rejects.toThrow('Error while performing wrapup'); }); -}); \ No newline at end of file +}); 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 bba8c44317e..6a1bd0d4102 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 @@ -6,13 +6,14 @@ import {CC_AGENT_EVENTS, CC_EVENTS} from '../../../../../src/services/config/typ import TaskManager from '../../../../../src/services/task/TaskManager'; import * as contact from '../../../../../src/services/task/contact'; import {TASK_EVENTS} from '../../../../../src/services/task/types'; +import {TaskEvent} from '../../../../../src/services/task/state-machine'; import WebRTC from '../../../../../src/services/task/voice/WebRTC'; import {Profile} from '../../../../../src/services/config/types'; import WebCallingService from '../../../../../src/services/WebCallingService'; import config from '../../../../../src/config'; import {CC_TASK_EVENTS} from '../../../../../src/services/config/types'; import TaskFactory from '../../../../../src/services/task/TaskFactory'; -import { wrap } from 'module'; +import LoggerProxy from '../../../../../src/logger-proxy'; describe('TaskManager', () => { let mockCall; @@ -85,6 +86,7 @@ describe('TaskManager', () => { accept: jest.fn(), decline: jest.fn(), updateTaskData: jest.fn(), + cancelAutoWrapupTimer: jest.fn(), data: taskDataMock, }; taskManager.call = mockCall; @@ -99,6 +101,7 @@ describe('TaskManager', () => { return task; }), unregisterWebCallListeners: jest.fn(), + cancelAutoWrapupTimer: jest.fn(), data, }; @@ -463,7 +466,7 @@ describe('TaskManager', () => { }); - it('should not emit TASK_HYDRATE if task is already present in taskManager', () => { + it('should emit TASK_HYDRATE even if task is already present in taskManager', () => { const payload = { data: { ...initalPayload.data, @@ -471,18 +474,16 @@ describe('TaskManager', () => { }, }; const taskEmitSpy = jest.spyOn(taskManager, 'emit'); + const existingTask = taskManager.getTask(taskId); webSocketManagerMock.emit('message', JSON.stringify(payload)); - expect(taskEmitSpy).not.toHaveBeenCalledWith( - TASK_EVENTS.TASK_HYDRATE, - taskManager.getTask(taskId) - ); + expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_HYDRATE, existingTask); expect(taskManager.taskCollection[payload.data.interactionId]).toBe( taskManager.getTask(taskId) ); }); - it('should emit TASK_INCOMING event on AGENT_CONTACT event if task is new and not in the taskManager ', () => { + it('should emit TASK_HYDRATE event on AGENT_CONTACT when task is created from payload', () => { taskManager.taskCollection = []; const payload = { data: { @@ -495,10 +496,7 @@ describe('TaskManager', () => { const taskEmitSpy = jest.spyOn(taskManager, 'emit'); webSocketManagerMock.emit('message', JSON.stringify(payload)); - expect(taskEmitSpy).toHaveBeenCalledWith( - TASK_EVENTS.TASK_INCOMING, - taskManager.getTask(taskId) - ); + expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_HYDRATE, taskManager.getTask(taskId)); expect(taskManager.taskCollection[payload.data.interactionId]).toBe( taskManager.getTask(taskId) ); @@ -606,7 +604,7 @@ describe('TaskManager', () => { const taskUpdateTaskDataSpy = jest.spyOn(taskManager.getTask(taskId), 'updateTaskData'); webSocketManagerMock.emit('message', JSON.stringify(payload)); expect(taskUpdateTaskDataSpy).toHaveBeenCalledWith(payload.data); - expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_UNHOLD, taskManager.getTask(taskId)); + expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_RESUME, taskManager.getTask(taskId)); }); it('handle AGENT_CONSULT_CREATED event', () => { @@ -1388,7 +1386,7 @@ describe('TaskManager', () => { }); describe('should emit appropriate task events for recording events', () => { - ['PAUSED', 'PAUSE_FAILED', 'RESUMED', 'RESUME_FAILED'].forEach((suffix) => { + ['STARTED', 'PAUSED', 'PAUSE_FAILED', 'RESUMED', 'RESUME_FAILED'].forEach((suffix) => { const ccEvent = CC_EVENTS[`CONTACT_RECORDING_${suffix}`]; const taskEvent = TASK_EVENTS[`TASK_RECORDING_${suffix}`]; it(`should emit ${taskEvent} on ${ccEvent} event`, () => { @@ -1405,384 +1403,95 @@ describe('TaskManager', () => { describe('Conference event handling', () => { let task; - const agentId = '723a8ffb-a26e-496d-b14a-ff44fb83b64f'; - + beforeEach(() => { - // Set the agentId on taskManager before tests run - taskManager.setAgentId(agentId); - task = { - data: { interactionId: taskId }, + data: {interactionId: taskId}, emit: jest.fn(), - updateTaskData: jest.fn().mockImplementation((updatedData) => { - // Mock the updateTaskData method to actually update task.data - task.data = { ...task.data, ...updatedData }; - return task; - }), - }; - taskManager.taskCollection[taskId] = task; - }); - - it('should handle AGENT_CONSULT_CONFERENCED event', () => { - const payload = { - data: { - type: CC_EVENTS.AGENT_CONSULT_CONFERENCED, - interactionId: taskId, - isConferencing: true, - }, - }; - - webSocketManagerMock.emit('message', JSON.stringify(payload)); - - expect(task.data.isConferencing).toBe(true); - expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_CONFERENCE_STARTED, task); - }); - - it('should handle AGENT_CONSULT_CONFERENCING event', () => { - const payload = { - data: { - type: CC_EVENTS.AGENT_CONSULT_CONFERENCING, - interactionId: taskId, - isConferencing: true, - }, - }; - - webSocketManagerMock.emit('message', JSON.stringify(payload)); - - expect(task.data.isConferencing).toBe(true); - // No task event emission for conferencing - only for conferenced (completed) - expect(task.emit).not.toHaveBeenCalledWith(TASK_EVENTS.TASK_CONFERENCE_STARTED, task); - }); - - it('should handle AGENT_CONSULT_CONFERENCE_FAILED event', () => { - const payload = { - data: { - type: CC_EVENTS.AGENT_CONSULT_CONFERENCE_FAILED, - interactionId: taskId, - reason: 'Network error', - }, - }; - - webSocketManagerMock.emit('message', JSON.stringify(payload)); - - expect(task.data.reason).toBe('Network error'); - // No event emission expected for failure - handled by contact method promise rejection - }); - - it('should handle PARTICIPANT_JOINED_CONFERENCE event', () => { - const payload = { - data: { - type: CC_EVENTS.PARTICIPANT_JOINED_CONFERENCE, - interactionId: taskId, - participantId: 'new-participant-123', - participantType: 'agent', - }, + updateTaskData: jest.fn(), }; - - webSocketManagerMock.emit('message', JSON.stringify(payload)); - - expect(task.data.participantId).toBe('new-participant-123'); - expect(task.data.participantType).toBe('agent'); - // No specific task event emission for participant joined - just data update + taskManager.taskCollection[taskId] = task as any; }); - describe('PARTICIPANT_LEFT_CONFERENCE event handling', () => { - it('should emit TASK_PARTICIPANT_LEFT event when participant leaves conference', () => { + const passThroughEvents = [ + CC_EVENTS.AGENT_CONSULT_CONFERENCED, + CC_EVENTS.AGENT_CONSULT_CONFERENCING, + CC_EVENTS.AGENT_CONSULT_CONFERENCE_FAILED, + CC_EVENTS.PARTICIPANT_JOINED_CONFERENCE, + CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, + CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE_FAILED, + ]; + + it.each(passThroughEvents)( + 're-emits %s payload without additional task-specific events', + (eventType) => { const payload = { data: { - type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, + type: eventType, interactionId: taskId, - interaction: { - participants: { - [agentId]: { - hasLeft: false, - }, - }, - }, }, }; webSocketManagerMock.emit('message', JSON.stringify(payload)); - expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task); - }); - - it('should NOT remove task when agent is still in interaction', () => { - const payload = { - data: { - type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, - interactionId: taskId, - interaction: { - participants: { - [agentId]: { - hasLeft: false, - }, - }, - }, - }, - }; - - webSocketManagerMock.emit('message', JSON.stringify(payload)); - - // Task should still exist in collection - expect(taskManager.getTask(taskId)).toBeDefined(); - expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task); - }); - - it('should NOT remove task when agent left but is in main interaction', () => { - const payload = { - data: { - type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, - interactionId: taskId, - interaction: { - participants: { - [agentId]: { - hasLeft: true, - }, - }, - media: { - [taskId]: { - mType: 'mainCall', - participants: [agentId], - }, - }, - }, - }, - }; - - const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection'); - - webSocketManagerMock.emit('message', JSON.stringify(payload)); - - // Task should still exist - not removed - expect(removeTaskSpy).not.toHaveBeenCalled(); - expect(taskManager.getTask(taskId)).toBeDefined(); - expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task); - }); - - it('should NOT remove task when agent left but is primary (owner)', () => { - const payload = { - data: { - type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, - interactionId: taskId, - interaction: { - participants: { - [agentId]: { - hasLeft: true, - }, - }, - owner: agentId, - media: { - [taskId]: { - mType: 'consultCall', - participants: ['other-agent'], - }, - }, - }, - }, - }; - - const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection'); - - webSocketManagerMock.emit('message', JSON.stringify(payload)); - - // Task should still exist - not removed because agent is primary - expect(removeTaskSpy).not.toHaveBeenCalled(); - expect(taskManager.getTask(taskId)).toBeDefined(); - expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task); - }); - - it('should remove task when agent left and is NOT in main interaction and is NOT primary', () => { - const payload = { - data: { - type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, - interactionId: taskId, - interaction: { - participants: { - [agentId]: { - hasLeft: true, - }, - }, - owner: 'another-agent-id', - media: { - [taskId]: { - mType: 'mainCall', - participants: ['another-agent-id'], - }, - }, - }, - }, - }; - - const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection'); - - webSocketManagerMock.emit('message', JSON.stringify(payload)); - - // Task should be removed - expect(removeTaskSpy).toHaveBeenCalled(); - expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task); - }); - - it('should remove task when agent is not in participants list', () => { - const payload = { - data: { - type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, - interactionId: taskId, - interaction: { - participants: { - 'other-agent-id': { - hasLeft: false, - }, - }, - owner: 'another-agent-id', - }, - }, - }; - - const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection'); - - webSocketManagerMock.emit('message', JSON.stringify(payload)); - - // Task should be removed because agent is not in participants - expect(removeTaskSpy).toHaveBeenCalled(); - expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task); - }); - - it('should update isConferenceInProgress based on remaining active agents', () => { - const payload = { - data: { - type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, - interactionId: taskId, - interaction: { - participants: { - [agentId]: { - hasLeft: false, - pType: 'Agent', - }, - 'agent-2': { - hasLeft: false, - pType: 'Agent', - }, - 'customer-1': { - hasLeft: false, - pType: 'Customer', - }, - }, - media: { - [taskId]: { - mType: 'mainCall', - participants: [agentId, 'agent-2', 'customer-1'], - }, - }, - }, - }, - }; - - webSocketManagerMock.emit('message', JSON.stringify(payload)); - - // isConferenceInProgress should be true (2 active agents) - expect(task.data.isConferenceInProgress).toBe(true); - expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task); - }); - - it('should set isConferenceInProgress to false when only one agent remains', () => { - const payload = { - data: { - type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, - interactionId: taskId, - interaction: { - participants: { - [agentId]: { - hasLeft: false, - pType: 'Agent', - }, - 'agent-2': { - hasLeft: true, - pType: 'Agent', - }, - 'customer-1': { - hasLeft: false, - pType: 'Customer', - }, - }, - media: { - [taskId]: { - mType: 'mainCall', - participants: [agentId, 'customer-1'], - }, - }, - }, - }, - }; - - webSocketManagerMock.emit('message', JSON.stringify(payload)); - - // isConferenceInProgress should be false (only 1 active agent) - expect(task.data.isConferenceInProgress).toBe(false); - expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task); - }); - - it('should handle participant left when no participants data exists', () => { - const payload = { - data: { - type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, - interactionId: taskId, - interaction: {}, - }, - }; - - const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection'); - - webSocketManagerMock.emit('message', JSON.stringify(payload)); + expect(task.emit).toHaveBeenCalledWith(eventType, payload.data); + } + ); - // When no participants data exists, checkParticipantNotInInteraction returns true - // Since agent won't be in main interaction either, task should be removed - expect(removeTaskSpy).toHaveBeenCalled(); - expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task); - }); - }); + it('only emits conference events for matching interactionId', () => { + const otherTaskId = 'other-task-id'; + const otherTask = {data: {interactionId: otherTaskId}, emit: jest.fn(), updateTaskData: jest.fn()}; + taskManager.taskCollection[otherTaskId] = otherTask as any; - it('should handle PARTICIPANT_LEFT_CONFERENCE_FAILED event', () => { const payload = { data: { - type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE_FAILED, + type: CC_EVENTS.AGENT_CONSULT_CONFERENCED, interactionId: taskId, - reason: 'Exit failed', }, }; webSocketManagerMock.emit('message', JSON.stringify(payload)); - expect(task.data.reason).toBe('Exit failed'); - // No event emission expected for failure - handled by contact method promise rejection + expect(task.emit).toHaveBeenCalledWith(CC_EVENTS.AGENT_CONSULT_CONFERENCED, payload.data); + expect(otherTask.emit).not.toHaveBeenCalled(); }); + }); - it('should only update task for matching interactionId', () => { - const otherTaskId = 'other-task-id'; - const otherTask = { - data: { interactionId: otherTaskId }, - emit: jest.fn(), - }; - taskManager.taskCollection[otherTaskId] = otherTask; + describe('state machine integration', () => { + it('maps CC events to task state machine events using normalized payload', () => { + const mapped = (TaskManager as any).mapEventToTaskStateMachineEvent( + CC_EVENTS.AGENT_CONTACT_ASSIGNED, + {...taskDataMock, type: CC_EVENTS.AGENT_CONTACT_ASSIGNED} + ); - const payload = { - data: { - type: CC_EVENTS.AGENT_CONSULT_CONFERENCED, - interactionId: taskId, - isConferencing: true, - }, - }; + expect(mapped).toEqual({ + type: TaskEvent.ASSIGN, + taskData: {...taskDataMock, type: CC_EVENTS.AGENT_CONTACT_ASSIGNED}, + }); + }); - webSocketManagerMock.emit('message', JSON.stringify(payload)); + it('sends mapped events to the task state machine service', () => { + const payload = {...taskDataMock, type: CC_EVENTS.AGENT_CONTACT_ASSIGNED}; + const send = jest.fn(); + const fakeTask = {stateMachineService: {send}}; + const logSpy = jest.spyOn(LoggerProxy, 'log'); - // Only the matching task should be updated - expect(task.data.isConferencing).toBe(true); - expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_CONFERENCE_STARTED, task); - - // Other task should not be affected - expect(otherTask.data.isConferencing).toBeUndefined(); - expect(otherTask.emit).not.toHaveBeenCalled(); + (taskManager as any).sendEventToStateMachine( + CC_EVENTS.AGENT_CONTACT_ASSIGNED, + payload, + fakeTask as any + ); + + expect(send).toHaveBeenCalledWith({ + type: TaskEvent.ASSIGN, + taskData: payload, + }); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('Sending event to state machine'), + expect.objectContaining({interactionId: payload.interactionId}) + ); + + logSpy.mockRestore(); }); - }); + }); }); - diff --git a/packages/@webex/contact-center/test/unit/spec/services/task/digital/Digital.ts b/packages/@webex/contact-center/test/unit/spec/services/task/digital/Digital.ts index 596e2e9f4b2..b2daec328be 100644 --- a/packages/@webex/contact-center/test/unit/spec/services/task/digital/Digital.ts +++ b/packages/@webex/contact-center/test/unit/spec/services/task/digital/Digital.ts @@ -1,6 +1,6 @@ import Digital from '../../../../../../src/services/task/digital/Digital'; -import { TaskData, TaskResponse } from '../../../../../../src/services/task/types'; -import { CC_EVENTS } from '../../../../../../src/services/config/types'; +import {TaskData, TaskResponse} from '../../../../../../src/services/task/types'; +import {TaskEvent, TaskEventPayload} from '../../../../../../src/services/task/state-machine'; jest.mock('../../../../../../src/services/core/WebexRequest', () => ({ __esModule: true, @@ -9,8 +9,20 @@ jest.mock('../../../../../../src/services/core/WebexRequest', () => ({ }, })); +const sendStateEvents = (task: Digital, events: TaskEventPayload[]) => { + events.forEach((event) => { + if (!event) { + throw new Error('Task event payload is required'); + } + task.stateMachineService?.send(event); + }); +}; + describe('Digital Task', () => { - const dummyData = { interactionId: 'dig1' } as TaskData; + const dummyData = { + interactionId: 'dig1', + interaction: {isTerminated: false}, + } as TaskData; let dummyContact: { accept: jest.Mock> }; beforeEach(() => { @@ -33,92 +45,61 @@ describe('Digital Task', () => { await expect(task.accept()).rejects.toThrow('Error while performing accept'); }); - it('constructor enables accept by default', () => { + it('constructor shows accept when offered', () => { const task = new Digital(dummyContact, dummyData); - // after constructor, accept visible & enabled - expect(task.taskUiControls.accept.visible).toBe(true); - expect(task.taskUiControls.accept.enabled).toBe(true); + sendStateEvents(task, [{type: TaskEvent.OFFER, taskData: dummyData}]); + expect(task.uiControls.accept.isVisible).toBe(true); + expect(task.uiControls.accept.isEnabled).toBe(true); }); - describe('setUIControls for AGENT_CONTACT events', () => { - function make(data: Partial & { type: string }) { - const full = { - interactionId: 'dig1', - interaction: { isTerminated: false, state: 'new' }, - ...data, - } as TaskData; - const task = new Digital(dummyContact, full); - task.updateTaskData(full); - return task.taskUiControls; - } - - it('new state shows accept only', () => { - const ctrl = make({ type: CC_EVENTS.AGENT_CONTACT, interaction: { isTerminated: false, state: 'new' } } as Partial & { type: string }); - expect(ctrl.accept.visible).toBe(true); - expect(ctrl.transfer.visible).toBe(false); - expect(ctrl.end.visible).toBe(false); - expect(ctrl.wrapup.visible).toBe(false); - }); - + describe('UI controls derived from state machine events', () => { it('connected state shows transfer and end', () => { - const ctrl = make({ type: CC_EVENTS.AGENT_CONTACT, interaction: { isTerminated: false, state: 'connected' } } as Partial & { type: string }); - expect(ctrl.accept.visible).toBe(false); - expect(ctrl.transfer.visible).toBe(true); - expect(ctrl.end.visible).toBe(true); - expect(ctrl.wrapup.visible).toBe(false); + const task = new Digital(dummyContact, dummyData); + sendStateEvents(task, [ + {type: TaskEvent.OFFER, taskData: dummyData}, + {type: TaskEvent.ASSIGN, taskData: dummyData}, + ]); + expect(task.uiControls.accept.isVisible).toBe(false); + expect(task.uiControls.transfer.isVisible).toBe(true); + expect(task.uiControls.end.isVisible).toBe(true); + expect(task.uiControls.wrapup.isVisible).toBe(false); }); - it('terminated shows wrapup only', () => { - const ctrl = make({ type: CC_EVENTS.AGENT_CONTACT, interaction: { isTerminated: true, state: 'connected' } } as Partial & { type: string }); - expect(ctrl.transfer.visible).toBe(false); - expect(ctrl.end.visible).toBe(false); - expect(ctrl.wrapup.visible).toBe(true); - expect(ctrl.wrapup.enabled).toBe(true); + it('wrapup state hides transfer/end and shows wrapup button', () => { + const task = new Digital(dummyContact, dummyData); + sendStateEvents(task, [ + {type: TaskEvent.OFFER, taskData: dummyData}, + {type: TaskEvent.ASSIGN, taskData: dummyData}, + {type: TaskEvent.END}, + ]); + expect(task.uiControls.transfer.isVisible).toBe(false); + expect(task.uiControls.end.isVisible).toBe(false); + expect(task.uiControls.wrapup.isVisible).toBe(true); }); - }); - describe('other CC_EVENTS paths', () => { - function ctrlFor(type: string) { - const data = { + it('terminated interaction toggles wrapup visibility even before END event', () => { + const task = new Digital(dummyContact, dummyData); + const terminatedData = { ...dummyData, - type, - interaction: { isTerminated: false, state: 'new' }, + interaction: {...(dummyData.interaction as any), isTerminated: true}, } as TaskData; - const task = new Digital(dummyContact, data); - task.updateTaskData(data); - return task.taskUiControls; - } - - it('AGENT_OFFER_CONTACT enables accept', () => { - const ctrl = ctrlFor(CC_EVENTS.AGENT_OFFER_CONTACT); - expect(ctrl.accept.visible).toBe(true); - }); - - it('AGENT_CONTACT_ASSIGNED shows transfer and end, hides accept', () => { - const ctrl = ctrlFor(CC_EVENTS.AGENT_CONTACT_ASSIGNED); - expect(ctrl.accept.visible).toBe(false); - expect(ctrl.transfer.visible).toBe(true); - expect(ctrl.end.visible).toBe(true); - }); - - it('AGENT_VTEAM_TRANSFERRED enables wrapup only', () => { - const ctrl = ctrlFor(CC_EVENTS.AGENT_VTEAM_TRANSFERRED); - expect(ctrl.transfer.visible).toBe(false); - expect(ctrl.end.visible).toBe(false); - expect(ctrl.wrapup.visible).toBe(true); - }); - - it('AGENT_WRAPUP enables wrapup only', () => { - const ctrl = ctrlFor(CC_EVENTS.AGENT_WRAPUP); - expect(ctrl.wrapup.visible).toBe(true); + task.updateTaskData(terminatedData); + sendStateEvents(task, [ + {type: TaskEvent.OFFER, taskData: dummyData}, + {type: TaskEvent.ASSIGN, taskData: terminatedData}, + ]); + expect(task.uiControls.wrapup.isVisible).toBe(true); }); - it('AGENT_CONTACT_OFFER_RONA disables accept, transfer, end, and wrapup', () => { - const ctrl = ctrlFor(CC_EVENTS.AGENT_CONTACT_OFFER_RONA); - expect(ctrl.accept.visible).toBe(false); - expect(ctrl.transfer.visible).toBe(false); - expect(ctrl.end.visible).toBe(false); - expect(ctrl.wrapup.visible).toBe(false); + it('rona hides accept controls', () => { + const task = new Digital(dummyContact, dummyData); + sendStateEvents(task, [ + {type: TaskEvent.OFFER, taskData: dummyData}, + {type: TaskEvent.RONA}, + ]); + expect(task.uiControls.accept.isVisible).toBe(false); + expect(task.uiControls.transfer.isVisible).toBe(false); + expect(task.uiControls.end.isVisible).toBe(false); }); }); }); diff --git a/packages/@webex/contact-center/test/unit/spec/services/task/state-machine/TaskStateMachine.ts b/packages/@webex/contact-center/test/unit/spec/services/task/state-machine/TaskStateMachine.ts new file mode 100644 index 00000000000..9734a598c68 --- /dev/null +++ b/packages/@webex/contact-center/test/unit/spec/services/task/state-machine/TaskStateMachine.ts @@ -0,0 +1,241 @@ +import {createActor} from 'xstate'; +import { + createTaskStateMachine, + TaskEvent, + TaskState, +} from '../../../../../../src/services/task/state-machine'; +import {createTaskData} from '../taskTestUtils'; + +const createConfig = () => ({ + channelType: 'voice' as const, + isEndCallEnabled: true, + isEndConsultEnabled: true, + voiceVariant: 'pstn' as const, + isRecordingEnabled: true, +}); + +describe('Task state machine', () => { + const startMachine = () => { + const actor = createActor(createTaskStateMachine(createConfig())); + actor.start(); + return actor; + }; + + describe('recording state derivation', () => { + it('captures recording flags from offer payload', () => { + const service = startMachine(); + const taskData = createTaskData({ + interaction: { + callProcessingDetails: { + recordInProgress: true, + isPaused: false, + }, + } as any, + }); + + service.send({type: TaskEvent.OFFER, taskData}); + + const snapshot = service.getSnapshot(); + expect(snapshot.context.recordingControlsAvailable).toBe(true); + expect(snapshot.context.recordingInProgress).toBe(true); + }); + + it('updates recordingPaused when ASSIGN payload reports pause', () => { + const service = startMachine(); + const initialTaskData = createTaskData(); + const pausedTaskData = createTaskData({ + interaction: { + callProcessingDetails: { + isPaused: true, + }, + } as any, + }); + + service.send({type: TaskEvent.OFFER, taskData: initialTaskData}); + service.send({type: TaskEvent.ASSIGN, taskData: pausedTaskData}); + + const snapshot = service.getSnapshot(); + expect(snapshot.context.recordingControlsAvailable).toBe(true); + expect(snapshot.context.recordingInProgress).toBe(false); + }); + + it('updates recording state when recording started event arrives', () => { + const service = startMachine(); + const initialTaskData = createTaskData(); + const recordingTaskData = createTaskData({ + interaction: { + callProcessingDetails: { + recordingStarted: true, + recordInProgress: true, + }, + } as any, + }); + + service.send({type: TaskEvent.OFFER, taskData: initialTaskData}); + service.send({type: TaskEvent.ASSIGN, taskData: initialTaskData}); + service.send({type: TaskEvent.RECORDING_STARTED, taskData: recordingTaskData}); + + const snapshot = service.getSnapshot(); + expect(snapshot.value).toBe(TaskState.CONNECTED); + expect(snapshot.context.recordingControlsAvailable).toBe(true); + expect(snapshot.context.recordingInProgress).toBe(true); + }); + }); + + describe('hold and resume flow', () => { + it('moves through HOLD -> HELD -> CONNECTED on success events', () => { + const service = startMachine(); + const taskData = createTaskData(); + + service.send({type: TaskEvent.OFFER, taskData}); + service.send({type: TaskEvent.ACCEPT}); + expect(service.getSnapshot().value).toBe(TaskState.CONNECTED); + + service.send({type: TaskEvent.HOLD, mediaResourceId: taskData.mediaResourceId}); + expect(service.getSnapshot().value).toBe(TaskState.HOLD_INITIATING); + + service.send({type: TaskEvent.HOLD_SUCCESS, mediaResourceId: taskData.mediaResourceId}); + expect(service.getSnapshot().value).toBe(TaskState.HELD); + + service.send({type: TaskEvent.UNHOLD, mediaResourceId: taskData.mediaResourceId}); + expect(service.getSnapshot().value).toBe(TaskState.RESUME_INITIATING); + + service.send({type: TaskEvent.UNHOLD_SUCCESS, mediaResourceId: taskData.mediaResourceId}); + expect(service.getSnapshot().value).toBe(TaskState.CONNECTED); + }); + }); + + describe('recording pause/resume events', () => { + it('toggles recordingPaused flag based on events', () => { + const service = startMachine(); + const taskData = createTaskData({ + interaction: { + callProcessingDetails: {recordInProgress: true}, + } as any, + }); + + service.send({type: TaskEvent.OFFER, taskData}); + service.send({type: TaskEvent.ASSIGN, taskData}); + expect(service.getSnapshot().context.recordingInProgress).toBe(true); + + service.send({type: TaskEvent.PAUSE_RECORDING}); + expect(service.getSnapshot().context.recordingInProgress).toBe(false); + + service.send({type: TaskEvent.RESUME_RECORDING}); + expect(service.getSnapshot().context.recordingInProgress).toBe(true); + }); + }); + + describe('wrap-up and completion flow', () => { + it('moves from CONNECTED -> WRAPPING_UP -> COMPLETED on END/WRAPUP', () => { + const service = startMachine(); + const taskData = createTaskData(); + + service.send({type: TaskEvent.OFFER, taskData}); + service.send({type: TaskEvent.ASSIGN, taskData}); + expect(service.getSnapshot().value).toBe(TaskState.CONNECTED); + + service.send({type: TaskEvent.END}); + expect(service.getSnapshot().value).toBe(TaskState.WRAPPING_UP); + + service.send({type: TaskEvent.WRAPUP}); + expect(service.getSnapshot().value).toBe(TaskState.COMPLETED); + }); + + it('handles CONTACT_ENDED by entering wrapping up before completion', () => { + const service = startMachine(); + const taskData = createTaskData(); + + service.send({type: TaskEvent.OFFER, taskData}); + service.send({type: TaskEvent.ASSIGN, taskData}); + + service.send({type: TaskEvent.CONTACT_ENDED}); + expect(service.getSnapshot().value).toBe(TaskState.WRAPPING_UP); + + service.send({type: TaskEvent.AUTO_WRAPUP}); + expect(service.getSnapshot().value).toBe(TaskState.COMPLETED); + }); + }); + + describe('consult and conference flows', () => { + it('tracks consult destination, agent join, and clears on consult end', () => { + const service = startMachine(); + const taskData = createTaskData(); + + service.send({type: TaskEvent.OFFER, taskData}); + service.send({type: TaskEvent.ASSIGN, taskData}); + + service.send({ + type: TaskEvent.CONSULT, + destination: 'agent-42', + destinationType: 'agent', + }); + expect(service.getSnapshot().value).toBe(TaskState.CONSULT_INITIATING); + expect(service.getSnapshot().context.consultInitiator).toBe(true); + expect(service.getSnapshot().context.consultDestination).toBe('agent-42'); + + service.send({type: TaskEvent.CONSULT_SUCCESS}); + expect(service.getSnapshot().value).toBe(TaskState.CONSULTING); + + service.send({ + type: TaskEvent.CONSULTING_ACTIVE, + consultDestinationAgentJoined: true, + }); + expect(service.getSnapshot().context.consultDestinationAgentJoined).toBe(true); + + service.send({type: TaskEvent.CONSULT_END}); + const snapshotAfterEnd = service.getSnapshot(); + expect(snapshotAfterEnd.value).toBe(TaskState.CONNECTED); + expect(snapshotAfterEnd.context.consultDestination).toBeNull(); + expect(snapshotAfterEnd.context.consultDestinationAgentJoined).toBe(false); + }); + + it('transitions to conferencing when merge event is received', () => { + const service = startMachine(); + const taskData = createTaskData(); + + service.send({type: TaskEvent.OFFER, taskData}); + service.send({type: TaskEvent.ASSIGN, taskData}); + service.send({type: TaskEvent.CONSULT_CREATED, taskData}); + expect(service.getSnapshot().value).toBe(TaskState.CONSULTING); + + service.send({type: TaskEvent.MERGE_TO_CONFERENCE}); + expect(service.getSnapshot().value).toBe(TaskState.CONFERENCING); + }); + }); + + describe('failure scenarios', () => { + it('returns to CONNECTED when HOLD fails', () => { + const service = startMachine(); + const taskData = createTaskData(); + + service.send({type: TaskEvent.OFFER, taskData}); + service.send({type: TaskEvent.ACCEPT}); + service.send({type: TaskEvent.HOLD, mediaResourceId: taskData.mediaResourceId}); + expect(service.getSnapshot().value).toBe(TaskState.HOLD_INITIATING); + + service.send({ + type: TaskEvent.HOLD_FAILED, + mediaResourceId: taskData.mediaResourceId, + }); + expect(service.getSnapshot().value).toBe(TaskState.CONNECTED); + }); + + it('falls back to HELD when UNHOLD fails', () => { + const service = startMachine(); + const taskData = createTaskData(); + + service.send({type: TaskEvent.OFFER, taskData}); + service.send({type: TaskEvent.ACCEPT}); + service.send({type: TaskEvent.HOLD, mediaResourceId: taskData.mediaResourceId}); + service.send({type: TaskEvent.HOLD_SUCCESS, mediaResourceId: taskData.mediaResourceId}); + expect(service.getSnapshot().value).toBe(TaskState.HELD); + + service.send({type: TaskEvent.UNHOLD, mediaResourceId: taskData.mediaResourceId}); + expect(service.getSnapshot().value).toBe(TaskState.RESUME_INITIATING); + + service.send({type: TaskEvent.UNHOLD_FAILED, mediaResourceId: taskData.mediaResourceId}); + expect(service.getSnapshot().value).toBe(TaskState.HELD); + }); + }); +}); diff --git a/packages/@webex/contact-center/test/unit/spec/services/task/taskTestUtils.ts b/packages/@webex/contact-center/test/unit/spec/services/task/taskTestUtils.ts new file mode 100644 index 00000000000..991f265d73c --- /dev/null +++ b/packages/@webex/contact-center/test/unit/spec/services/task/taskTestUtils.ts @@ -0,0 +1,87 @@ +import {MEDIA_CHANNEL, TaskData} from '../../../../../src/services/task/types'; + +type TaskDataOverrides = Partial & { + interaction?: Partial & { + media?: Record; + callProcessingDetails?: Record; + }; +}; + +/** + * Utility to create task data for tests with sensible defaults while allowing overrides. + */ +export function createTaskData(overrides: TaskDataOverrides = {}): TaskData { + const base: TaskData = { + interactionId: 'interaction-1', + mediaResourceId: 'media-1', + eventType: 'OFFER', + agentId: 'agent-1', + destAgentId: 'agent-2', + trackingId: 'tracking-1', + consultMediaResourceId: 'media-1', + interaction: { + isFcManaged: false, + isTerminated: false, + mediaType: MEDIA_CHANNEL.TELEPHONY, + previousVTeams: [], + state: 'new', + currentVTeam: 'team-1', + participants: [], + interactionId: 'interaction-1', + orgId: 'org-1', + callProcessingDetails: { + recordingStarted: true, + recordInProgress: true, + }, + media: { + 'media-1': { + mediaResourceId: 'media-1', + isHold: false, + }, + }, + } as any, + } as TaskData; + + const mergedInteraction = { + ...(base.interaction as any), + ...(overrides.interaction || {}), + media: { + ...((base.interaction as any).media || {}), + ...((overrides.interaction as any)?.media || {}), + }, + callProcessingDetails: { + ...((base.interaction as any).callProcessingDetails || {}), + ...((overrides.interaction as any)?.callProcessingDetails || {}), + }, + }; + + return { + ...base, + ...overrides, + interaction: mergedInteraction, + } as TaskData; +} + +describe('taskTestUtils', () => { + it('creates sensible defaults when no overrides are provided', () => { + const task = createTaskData(); + + expect(task.interactionId).toBe('interaction-1'); + expect(task.interaction?.state).toBe('new'); + expect(task.interaction?.media?.['media-1']?.isHold).toBe(false); + }); + + it('merges nested interaction overrides', () => { + const task = createTaskData({ + interaction: { + state: 'connected', + media: { + 'media-1': {mediaResourceId: 'media-1', isHold: true}, + }, + }, + }); + + expect(task.interaction?.state).toBe('connected'); + expect(task.interaction?.media?.['media-1']?.isHold).toBe(true); + }); +}); diff --git a/packages/@webex/contact-center/test/unit/spec/services/task/voice/Voice.ts b/packages/@webex/contact-center/test/unit/spec/services/task/voice/Voice.ts index 45336da2e52..e8d41d38bbc 100644 --- a/packages/@webex/contact-center/test/unit/spec/services/task/voice/Voice.ts +++ b/packages/@webex/contact-center/test/unit/spec/services/task/voice/Voice.ts @@ -1,8 +1,9 @@ import Voice from '../../../../../../src/services/task/voice/Voice'; -import { TaskData } from '../../../../../../src/services/task/types'; -import { CC_EVENTS } from '../../../../../../src/services/config/types'; -import { CONSULT_TRANSFER_DESTINATION_TYPE } from '../../../../../../src/services/task/types'; -import e from 'express'; +import {TaskData, CONSULT_TRANSFER_DESTINATION_TYPE} from '../../../../../../src/services/task/types'; +import {CC_EVENTS} from '../../../../../../src/services/config/types'; +import {TaskEvent, TaskState} from '../../../../../../src/services/task/state-machine'; +import {computeUIControls} from '../../../../../../src/services/task/state-machine/uiControlsComputer'; +import {createTaskData} from '../taskTestUtils'; jest.mock('../../../../../../src/services/core/WebexRequest', () => ({ __esModule: true, @@ -16,36 +17,63 @@ jest.mock('../../../../../../src/services/core/Utils', () => ({ getErrorDetails: (err: any) => ({ error: err }), })); -describe('Voice Task', () => { - const dummyContact = { - hold: jest.fn().mockResolvedValue('held'), - unHold: jest.fn().mockResolvedValue('resumed'), - pauseRecording: jest.fn().mockResolvedValue('paused'), - resumeRecording: jest.fn().mockResolvedValue('resumedRecording'), - consult: jest.fn().mockResolvedValue('consulted'), - } as any; - - const baseData = { +const dummyContact = { + hold: jest.fn().mockResolvedValue('held'), + unHold: jest.fn().mockResolvedValue('resumed'), + pauseRecording: jest.fn().mockResolvedValue('paused'), + resumeRecording: jest.fn().mockResolvedValue('resumedRecording'), + consult: jest.fn().mockResolvedValue('consulted'), + consultTransfer: jest.fn().mockResolvedValue('consultTransferred'), +} as any; + +const createBaseData = (overrides: Partial = {}): TaskData => + createTaskData({ interactionId: 'int1', mediaResourceId: 'media1', interaction: { - mainInteractionId: 'main1', - media: { 'media1': { mediaResourceId: 'media1', isHold: false }}, + ...(overrides.interaction || {}), + media: { + media1: {mediaResourceId: 'media1', isHold: false}, + ...(overrides.interaction as any)?.media, + }, }, - } as unknown as TaskData; + ...overrides, + }); + +const primeConnectedState = (voice: Voice, taskData: TaskData) => { + voice.stateMachineService?.send({type: TaskEvent.OFFER, taskData}); + voice.stateMachineService?.send({type: TaskEvent.ASSIGN, taskData}); +}; + +const primeHeldState = (voice: Voice, taskData: TaskData) => { + primeConnectedState(voice, taskData); + voice.stateMachineService?.send({type: TaskEvent.HOLD, mediaResourceId: taskData.mediaResourceId}); + voice.stateMachineService?.send({ + type: TaskEvent.HOLD_SUCCESS, + mediaResourceId: taskData.mediaResourceId, + }); +}; + +describe('Voice Task', () => { + + beforeEach(() => { + jest.clearAllMocks(); + }); it('hides end and endConsult when disabled', () => { - const voice = new Voice(dummyContact, baseData, { + const voice = new Voice(dummyContact, createBaseData(), { isEndCallEnabled: false, isEndConsultEnabled: false, }); - voice.updateTaskData(baseData); - expect(voice.taskUiControls.end.visible).toBe(false); - expect(voice.taskUiControls.endConsult.visible).toBe(false); + voice.updateTaskData(createBaseData()); + expect(voice.uiControls.end.isVisible).toBe(false); + expect(voice.uiControls.endConsult.isVisible).toBe(false); }); it('calls contact.hold when media is not held', async () => { - const voice = new Voice(dummyContact, baseData as any, {}); + const taskData = createBaseData() as any; + const voice = new Voice(dummyContact, taskData, {}); + primeConnectedState(voice, taskData); await voice.holdResume(); expect(dummyContact.hold).toHaveBeenCalledWith({ interactionId: 'int1', @@ -54,14 +82,13 @@ describe('Voice Task', () => { }); it('calls contact.unHold when media is held', async () => { - const heldData = { - ...baseData, + const heldData = createBaseData({ interaction: { - ...baseData.interaction, - media: { 'media1': { mediaResourceId: 'media1', isHold: true }}, - }, - } as any; + media: {'media1': {mediaResourceId: 'media1', isHold: true}}, + } as any, + }) as any; const voice = new Voice(dummyContact, heldData, {}); + primeHeldState(voice, heldData); await voice.holdResume(); expect(dummyContact.unHold).toHaveBeenCalledWith({ interactionId: 'int1', @@ -70,19 +97,24 @@ describe('Voice Task', () => { }); it('pauseRecording() calls contact.pauseRecording', async () => { - const voice = new Voice(dummyContact, baseData, { + const taskData = createBaseData(); + const voice = new Voice(dummyContact, taskData, { isEndCallEnabled: true, isEndConsultEnabled: true, }); + primeConnectedState(voice, taskData); const res = await voice.pauseRecording(); expect(dummyContact.pauseRecording).toHaveBeenCalledWith({ interactionId: 'int1' }); }); it('resumeRecording() with no payload defaults to autoResumed false', async () => { - const voice = new Voice(dummyContact, baseData, { + const taskData = createBaseData(); + const voice = new Voice(dummyContact, taskData, { isEndCallEnabled: true, isEndConsultEnabled: true, }); + primeConnectedState(voice, taskData); + voice.stateMachineService?.send({type: TaskEvent.PAUSE_RECORDING}); const res = await voice.resumeRecording(); expect(dummyContact.resumeRecording).toHaveBeenCalledWith({ interactionId: 'int1', @@ -91,10 +123,12 @@ describe('Voice Task', () => { }); it('consult() calls contact.consult with payload', async () => { - const voice = new Voice(dummyContact, baseData, { + const taskData = createBaseData(); + const voice = new Voice(dummyContact, taskData, { isEndCallEnabled: true, isEndConsultEnabled: true, }); + primeConnectedState(voice, taskData); const payload = { destination: 'agent1', destinationType: 'agent' } as any; const res = await voice.consult(payload); expect(dummyContact.consult).toHaveBeenCalledWith({ @@ -106,10 +140,9 @@ describe('Voice Task', () => { describe('transfer()', () => { it('calls contact.consultTransfer for consult transfer to agent', async () => { const consultTransferMock = jest.fn().mockResolvedValue('consultedA'); - const dataWithState = { - ...baseData, - interaction: { ...baseData.interaction, state: 'consulting' }, - }; + const dataWithState = createBaseData({ + interaction: {state: 'consulting'} as any, + }); const voice = new Voice( { ...dummyContact, consultTransfer: consultTransferMock }, dataWithState as any, @@ -128,10 +161,10 @@ describe('Voice Task', () => { }); it('throws if consult transfer to QUEUE but no destAgentId set', async () => { - const dataWithState = { - ...baseData, - interaction: { ...baseData.interaction, state: 'consulting' }, - }; + const dataWithState = createBaseData({ + destAgentId: undefined, + interaction: {state: 'consulting'} as any, + }); const voice = new Voice(dummyContact, dataWithState as any, { isEndCallEnabled: true, isEndConsultEnabled: true, @@ -147,11 +180,10 @@ describe('Voice Task', () => { it('uses data.destAgentId for queue consult transfer', async () => { const consultTransferMock = jest.fn().mockResolvedValue('consultedQ'); - const dataWithDest = { - ...baseData, + const dataWithDest = createBaseData({ destAgentId: 'agentD', - interaction: { ...baseData.interaction, state: 'consulting' }, - }; + interaction: {state: 'consulting'} as any, + }); const voice = new Voice( { ...dummyContact, consultTransfer: consultTransferMock }, dataWithDest as any, @@ -178,7 +210,7 @@ describe('Voice Task', () => { const consultEndMock = jest.fn().mockResolvedValue('endedC'); const voice = new Voice( { ...dummyContact, consultEnd: consultEndMock }, - baseData, + createBaseData(), { isEndCallEnabled: true, isEndConsultEnabled: true } ); const payload = { isConsult: true, queueId: 'q1', taskId: 't1' }; @@ -194,291 +226,95 @@ describe('Voice Task', () => { describe('UI controls for AGENT_CONTACT_ASSIGNED', () => { it('shows main controls and hides accept/decline on AGENT_CONTACT_ASSIGNED', () => { - const data: any = { ...baseData, type: CC_EVENTS.AGENT_CONTACT_ASSIGNED }; + const data: any = { ...createBaseData(), type: CC_EVENTS.AGENT_CONTACT_ASSIGNED }; const voice = new Voice(dummyContact, data, { isEndCallEnabled: true, isEndConsultEnabled: false, }); voice.updateTaskData(data); + primeConnectedState(voice, data); - expect(voice.taskUiControls.accept.visible).toBe(false); - expect(voice.taskUiControls.decline.visible).toBe(false); - expect(voice.taskUiControls.hold.visible).toBe(true); - expect(voice.taskUiControls.transfer.visible).toBe(true); - expect(voice.taskUiControls.consult.visible).toBe(true); - expect(voice.taskUiControls.recording.visible).toBe(true); - expect(voice.taskUiControls.end.visible).toBe(true); - expect(voice.taskUiControls.endConsult.visible).toBe(false); + expect(voice.uiControls.accept.isVisible).toBe(false); + expect(voice.uiControls.decline.isVisible).toBe(false); + expect(voice.uiControls.hold.isVisible).toBe(true); + expect(voice.uiControls.transfer.isVisible).toBe(true); + expect(voice.uiControls.consult.isVisible).toBe(true); + expect(voice.uiControls.recording.isVisible).toBe(true); + expect(voice.uiControls.end.isVisible).toBe(true); + expect(voice.uiControls.endConsult.isVisible).toBe(false); }); }); - describe('UI controls for various CC_EVENTS', () => { - const make = (evt: any, opts: any = {}) => { - const data: any = { - ...baseData, - type: evt, - interaction: { ...baseData.interaction, state: opts.state || 'active' }, - isConsulted: opts.isConsulted, - destAgentId: opts.destAgentId, - }; - const voice = new Voice(dummyContact, data, { - isEndCallEnabled: opts.endCall ?? true, - isEndConsultEnabled: opts.endConsult ?? true, - }); - voice.updateTaskData(data); - return voice.taskUiControls; - }; - - it('AGENT_CONTACT_UNASSIGNED hides consultTransfer/recording/end and shows wrapup', () => { - const ctrl = make(CC_EVENTS.AGENT_CONTACT_UNASSIGNED); - expect(ctrl.consultTransfer.visible).toBe(false); - expect(ctrl.recording.visible).toBe(false); - expect(ctrl.end.visible).toBe(false); - expect(ctrl.wrapup.visible).toBe(true); - expect(ctrl.wrapup.enabled).toBe(true); - }); - - it('CONTACT_ENDED with state new hides all and no wrapup', () => { - const ctrl = make(CC_EVENTS.CONTACT_ENDED, { state: 'new' }); - ['hold', 'transfer', 'consult', 'consultTransfer', 'recording', 'end', 'endConsult', 'wrapup'].forEach( - (k) => expect((ctrl as any)[k].visible).toBe(false) - ); - }); - - it('CONTACT_ENDED with state active hides all except wrapup', () => { - const ctrl = make(CC_EVENTS.CONTACT_ENDED, { state: 'ended' }); - ['hold', 'transfer', 'consult', 'consultTransfer', 'recording', 'end', 'endConsult'].forEach( - (k) => expect((ctrl as any)[k].visible).toBe(false) - ); - expect(ctrl.wrapup.visible).toBe(true); - expect(ctrl.wrapup.enabled).toBe(true); - }); - - it('AGENT_CONTACT_HELD shows main controls and end disabled', () => { - const ctrl = make(CC_EVENTS.AGENT_CONTACT_HELD); - ['hold', 'transfer', 'consult', 'recording'].forEach((k) => - expect((ctrl as any)[k].visible).toBe(true) + describe('state machine derived controls', () => { + it('keeps uiControls in sync with state machine context', () => { + const taskData = createBaseData(); + const voice = new Voice(dummyContact, taskData, {}); + const initialSnapshot = voice.stateMachineService?.getSnapshot(); + const initialExpected = computeUIControls( + initialSnapshot?.value as TaskState, + initialSnapshot?.context as any, + voice.data ); - expect(ctrl.end.visible).toBe(true); - expect(ctrl.end.enabled).toBe(false); - }); - - it('AGENT_CONTACT_UNHELD shows main controls and end enabled', () => { - const ctrl = make(CC_EVENTS.AGENT_CONTACT_UNHELD); - ['hold', 'transfer', 'consult', 'recording'].forEach((k) => - expect((ctrl as any)[k].visible).toBe(true) - ); - expect(ctrl.end.visible).toBe(true); - expect(ctrl.end.enabled).toBe(true); - }); + expect(voice.uiControls).toEqual(initialExpected); - it('AGENT_VTEAM_TRANSFERRED hides all except wrapup', () => { - const ctrl = make(CC_EVENTS.AGENT_VTEAM_TRANSFERRED); - ['hold', 'transfer', 'consult', 'consultTransfer', 'recording', 'end'].forEach((k) => - expect((ctrl as any)[k].visible).toBe(false) - ); - expect(ctrl.wrapup.visible).toBe(true); - expect(ctrl.wrapup.enabled).toBe(true); - }); + voice.updateTaskData(taskData); + voice.stateMachineService?.send({type: TaskEvent.OFFER, taskData}); + voice.stateMachineService?.send({type: TaskEvent.ASSIGN, taskData}); - it('AGENT_CTQ_CANCEL_FAILED shows main and end enabled', () => { - const ctrl = make(CC_EVENTS.AGENT_CTQ_CANCEL_FAILED); - ['hold', 'transfer', 'consult', 'recording'].forEach((k) => - expect((ctrl as any)[k].visible).toBe(true) + const snapshot = voice.stateMachineService?.getSnapshot(); + const expected = computeUIControls( + snapshot?.value as TaskState, + snapshot?.context as any, + voice.data ); - expect(ctrl.end.visible).toBe(true); - expect(ctrl.end.enabled).toBe(true); - }); - - it('AGENT_CONSULT_CREATED when not consulted toggles correctly', () => { - const ctrl = make(CC_EVENTS.AGENT_CONSULT_CREATED, { isConsulted: false }); - ['hold', 'consult'].forEach((k) => - expect((ctrl as any)[k].visible).toBe(false) - ); - expect(ctrl.end.visible).toBe(true); - expect(ctrl.end.enabled).toBe(false); - expect(ctrl.transfer.visible).toBe(true); - expect(ctrl.transfer.enabled).toBe(false); - expect(ctrl.consultTransfer.visible).toBe(true); - expect(ctrl.consultTransfer.enabled).toBe(false); - expect(ctrl.recording.visible).toBe(true); - expect(ctrl.recording.enabled).toBe(false); - expect(ctrl.endConsult.visible).toBe(true); - expect(ctrl.endConsult.enabled).toBe(true); - }); - - it('AGENT_OFFER_CONSULT respects endConsult flag', () => { - const ctrl1 = make(CC_EVENTS.AGENT_OFFER_CONSULT, { endConsult: true }); - expect(ctrl1.endConsult.visible).toBe(true); - expect(ctrl1.endConsult.enabled).toBe(true); - const ctrl2 = make(CC_EVENTS.AGENT_OFFER_CONSULT, { endConsult: false }); - expect(ctrl2.endConsult.visible).toBe(false); - }); - - it('AGENT_CONSULTING when starting hides main and shows consultTransfer etc.', () => { - const ctrl = make(CC_EVENTS.AGENT_CONSULTING, { isConsulted: false }); - ['hold', 'consult'].forEach((k) => - expect((ctrl as any)[k].visible).toBe(false) - ); - expect(ctrl.transfer.visible).toBe(true); - expect(ctrl.transfer.enabled).toBe(false); - expect(ctrl.consultTransfer.visible).toBe(true); - expect(ctrl.consultTransfer.enabled).toBe(true); - expect(ctrl.recording.visible).toBe(true); - expect(ctrl.recording.enabled).toBe(false); - expect(ctrl.endConsult.visible).toBe(true); - expect(ctrl.endConsult.enabled).toBe(true); - expect(ctrl.end.visible).toBe(true); - expect(ctrl.end.enabled).toBe(false); - }); - - it('AGENT_CONSULTING when consulted only shows endConsult if allowed', () => { - const ctrl = make(CC_EVENTS.AGENT_CONSULTING, { isConsulted: true, endConsult: true }); - expect(ctrl.endConsult.visible).toBe(true); - expect(ctrl.endConsult.enabled).toBe(true); - }); - - it('AGENT_CONSULT_FAILED resets to main and hides transfer/wrapup', () => { - const ctrl = make(CC_EVENTS.AGENT_CONSULT_FAILED, { isConsulted: false }); - ['hold', 'transfer', 'consult', 'recording'].forEach((k) => - expect((ctrl as any)[k].visible).toBe(true) - ); - expect(ctrl.end.visible).toBe(true); - expect(ctrl.consultTransfer.visible).toBe(false); - expect(ctrl.wrapup.visible).toBe(false); + expect(voice.uiControls).toEqual(expected); }); }); - describe('UI controls for AGENT_CONTACT', () => { - const makeContact = (opts: { - state: string; - isConsulted?: boolean; - isTerminated?: boolean; - endCall?: boolean; - endConsult?: boolean; - }) => { - const data: any = { - ...baseData, - type: CC_EVENTS.AGENT_CONTACT, + describe('recording operations', () => { + const buildRecordingData = (recordingOverrides: Record) => + createBaseData({ interaction: { - ...baseData.interaction, - state: opts.state, - isTerminated: opts.isTerminated || false, - }, - isConsulted: opts.isConsulted || false, - }; - const voice = new Voice(dummyContact, data, { - isEndCallEnabled: opts.endCall ?? true, - isEndConsultEnabled: opts.endConsult ?? true, + callProcessingDetails: recordingOverrides, + } as any, }); - voice.updateTaskData(data); - return voice.taskUiControls; - }; - it('hides all and shows wrapup when terminated', () => { - const ctrl = makeContact({ state: 'connected', isTerminated: true }); - ['hold', 'transfer', 'consult', 'consultTransfer', 'recording', 'end'].forEach((k) => - expect((ctrl as any)[k].visible).toBe(false) - ); - expect(ctrl.wrapup.visible).toBe(true); - expect(ctrl.wrapup.enabled).toBe(true); + it('throws when pauseRecording is invoked without active recording', async () => { + const voice = new Voice(dummyContact, createBaseData(), {}); + await expect(voice.pauseRecording()).rejects.toThrow('Recording is not active or already paused'); + expect(dummyContact.pauseRecording).not.toHaveBeenCalled(); }); - it('shows main and end enabled when connected (not consulted)', () => { - const ctrl = makeContact({ state: 'connected', isConsulted: false }); - ['hold', 'transfer', 'consult', 'recording'].forEach((k) => - expect((ctrl as any)[k].visible).toBe(true) - ); - expect(ctrl.end.visible).toBe(true); - expect(ctrl.end.enabled).toBe(true); - }); + it('pauses recording when state machine context indicates active recording', async () => { + const taskData = buildRecordingData({recordInProgress: true}); + const voice = new Voice(dummyContact, taskData, {}); + primeConnectedState(voice, taskData); - it('consulting (not consulted) hides main, shows consultTransfer/endConsult, end disabled', () => { - const ctrl = makeContact({ state: 'consulting', isConsulted: false, endCall: true }); - ['hold', 'transfer', 'consult'].forEach((k) => - expect((ctrl as any)[k].visible).toBe(false) - ); - expect(ctrl.consultTransfer.visible).toBe(true); - expect(ctrl.consultTransfer.enabled).toBe(true); - expect(ctrl.endConsult.visible).toBe(true); - expect(ctrl.endConsult.enabled).toBe(true); - expect(ctrl.end.visible).toBe(true); - expect(ctrl.end.enabled).toBe(false); + await voice.pauseRecording(); + expect(dummyContact.pauseRecording).toHaveBeenCalledWith({interactionId: 'int1'}); }); - it('consulting (consulted) hides main and shows only endConsult when allowed', () => { - const ctrl = makeContact({ state: 'consulting', isConsulted: true, endConsult: true }); - ['hold', 'transfer', 'consult', 'consultTransfer'].forEach((k) => - expect((ctrl as any)[k].visible).toBe(false) - ); - expect(ctrl.recording.visible).toBe(true); - expect(ctrl.recording.enabled).toBe(false); - expect(ctrl.endConsult.visible).toBe(true); - expect(ctrl.endConsult.enabled).toBe(true); - expect(ctrl.end.visible).toBe(false); - }); - }); - describe('state machine integration', () => { - it('should have a stateMachine property', () => { - const voice = new Voice(dummyContact, baseData, {}); - expect(voice.stateMachine).toBeDefined(); - }); + it('throws if resumeRecording is invoked while recording is not paused', async () => { + const taskData = buildRecordingData({recordInProgress: true}); + const voice = new Voice(dummyContact, taskData, {}); + primeConnectedState(voice, taskData); - it('should be in the initial state', () => { - const voice = new Voice(dummyContact, baseData, {}); - expect(voice.state).toBe('Idle'); - expect(voice.isRinging).toBe(false); + await expect(voice.resumeRecording()).rejects.toThrow('Recording is not paused'); + expect(dummyContact.resumeRecording).not.toHaveBeenCalled(); }); - it('should transition to Ringing on AGENT_OFFER_CONTACT', () => { - const voice = new Voice(dummyContact, baseData, {}); - voice.updateTaskData({ - ...baseData, - type: CC_EVENTS.AGENT_OFFER_CONTACT, - } as any); - expect(voice.state).toBe('Ringing'); - expect(voice.isRinging).toBe(true); - }); + it('resumes recording when context shows paused recording', async () => { + const taskData = buildRecordingData({recordInProgress: true}); + const voice = new Voice(dummyContact, taskData, {}); + primeConnectedState(voice, taskData); + voice.stateMachineService?.send({type: TaskEvent.PAUSE_RECORDING}); - it('should transition to Connected on AGENT_CONTACT_ASSIGNED', () => { - const voice = new Voice(dummyContact, baseData, {}); - voice.updateTaskData({ - ...baseData, - type: CC_EVENTS.AGENT_CONTACT_ASSIGNED, - } as any); - expect(voice.state).toBe('Connected'); - expect(voice.isRinging).toBe(false); - }); - - it('should transition to Held on AGENT_CONTACT_HELD', () => { - const voice = new Voice(dummyContact, baseData, {}); - voice.updateTaskData({ - ...baseData, - type: CC_EVENTS.AGENT_CONTACT_HELD, - } as any); - expect(voice.state).toBe('Held'); - expect(voice.isRinging).toBe(false); - }); - - it('should transition to Consulting on AGENT_CONSULTING', () => { - const voice = new Voice(dummyContact, baseData, {}); - voice.updateTaskData({ - ...baseData, - type: CC_EVENTS.AGENT_CONSULTING, - } as any); - expect(voice.state).toBe('Consulting'); - expect(voice.isRinging).toBe(false); - }); - - it('should transition to WrapUp on AGENT_CONTACT_UNASSIGNED', () => { - const voice = new Voice(dummyContact, baseData, {}); - voice.updateTaskData({ - ...baseData, - type: CC_EVENTS.AGENT_CONTACT_UNASSIGNED, - } as any); - expect(voice.state).toBe('WrapUp'); - expect(voice.isRinging).toBe(false); + await voice.resumeRecording(); + expect(dummyContact.resumeRecording).toHaveBeenCalledWith({ + interactionId: 'int1', + data: {autoResumed: false}, + }); }); }); }); diff --git a/packages/@webex/contact-center/test/unit/spec/services/task/voice/WebRTC.ts b/packages/@webex/contact-center/test/unit/spec/services/task/voice/WebRTC.ts index e482361348b..7f7b185fdb2 100644 --- a/packages/@webex/contact-center/test/unit/spec/services/task/voice/WebRTC.ts +++ b/packages/@webex/contact-center/test/unit/spec/services/task/voice/WebRTC.ts @@ -2,9 +2,10 @@ import 'jsdom-global/register'; import { LocalMicrophoneStream, CALL_EVENT_KEYS } from '@webex/calling'; import WebRTC from '../../../../../../src/services/task/voice/WebRTC'; import WebCallingService from '../../../../../../src/services/WebCallingService'; -import { TaskData, TASK_EVENTS } from '../../../../../../src/services/task/types'; -import type { WebexSDK } from '../../../../../../src/types'; -import { CC_EVENTS } from '../../../../../../src/services/config/types'; +import {TaskData, TASK_EVENTS} from '../../../../../../src/services/task/types'; +import type {WebexSDK} from '../../../../../../src/types'; +import {TaskEvent, TaskEventPayload} from '../../../../../../src/services/task/state-machine'; +import {createTaskData} from '../taskTestUtils'; jest.mock('@webex/calling', () => ({ LocalMicrophoneStream: class { @@ -31,9 +32,18 @@ jest.mock('../../../../../../src/services/core/WebexRequest', () => ({ }, })); +const sendStateEvents = (task: WebRTC, events: TaskEventPayload[]) => { + events.forEach((event) => { + if (!event) { + throw new Error('Task event payload is required'); + } + task.stateMachineService?.send(event); + }); +}; + describe('WebRTC Task', () => { const dummyContact = {} as any; - const data = { interactionId: 'int1', type: 'dummyType', interaction: {state: 'connected'} } as TaskData; + let taskData: TaskData; let webCallingService: WebCallingService; let onSpy: jest.SpyInstance; let offSpy: jest.SpyInstance; @@ -61,12 +71,11 @@ describe('WebRTC Task', () => { }); beforeEach(() => { - webRtc = new WebRTC( - dummyContact, - webCallingService, - data, - { isEndCallEnabled: true, isEndConsultEnabled: true } - ); + taskData = createTaskData(); + webRtc = new WebRTC(dummyContact, webCallingService, taskData, { + isEndCallEnabled: true, + isEndConsultEnabled: true, + }); }); it('accept() obtains media and answers call', async () => { @@ -77,13 +86,13 @@ describe('WebRTC Task', () => { expect(answerSpy).toHaveBeenCalled(); const [[streamArg, interactionIdArg]] = webCallingService.answerCall.mock.calls; expect(streamArg).toBeInstanceOf(LocalMicrophoneStream); - expect(interactionIdArg).toBe('int1'); + expect(interactionIdArg).toBe(taskData.interactionId); }); it('decline() calls declineCall and unregisters listeners', async () => { jest.spyOn(webRtc, 'unregisterWebCallListeners'); const res = await webRtc.decline(); - expect(declineSpy).toHaveBeenCalledWith('int1'); + expect(declineSpy).toHaveBeenCalledWith(taskData.interactionId); expect((webRtc).unregisterWebCallListeners).toHaveBeenCalled(); expect(res).toBeUndefined(); }); @@ -117,70 +126,103 @@ describe('WebRTC Task', () => { describe('UI controls', () => { beforeEach(() => { - webRtc = new WebRTC(dummyContact, webCallingService, data, { + taskData = createTaskData(); + webRtc = new WebRTC(dummyContact, webCallingService, taskData, { isEndCallEnabled: true, isEndConsultEnabled: true, }); }); it('initialiseUIControls sets accept and decline visible', () => { - expect(webRtc.taskUiControls.accept.visible).toBe(true); - expect(webRtc.taskUiControls.decline.visible).toBe(true); + sendStateEvents(webRtc, [{type: TaskEvent.OFFER, taskData}]); + expect(webRtc.uiControls.accept.isVisible).toBe(true); + expect(webRtc.uiControls.decline.isVisible).toBe(true); }); it('setUIControls for AGENT_CONTACT_ASSIGNED shows mute enabled', () => { - webRtc.updateTaskData({ ...data, type: CC_EVENTS.AGENT_CONTACT_ASSIGNED }); - expect(webRtc.taskUiControls.mute.visible).toBe(true); - expect(webRtc.taskUiControls.mute.enabled).toBe(true); - }); + sendStateEvents(webRtc, [ + {type: TaskEvent.OFFER, taskData}, + {type: TaskEvent.ASSIGN, taskData}, + ]); + expect(webRtc.uiControls.mute.isVisible).toBe(true); + expect(webRtc.uiControls.mute.isEnabled).toBe(true); + }); - it('default setUIControls hides mute when wrapup visible', () => { - webRtc.updateTaskData({ ...data, type: CC_EVENTS.CONTACT_ENDED }); - expect(webRtc.taskUiControls.wrapup.visible).toBe(true); - expect(webRtc.taskUiControls.mute.visible).toBe(false); - }); + it('default setUIControls hides mute when wrapup visible', () => { + sendStateEvents(webRtc, [ + {type: TaskEvent.OFFER, taskData}, + {type: TaskEvent.ASSIGN, taskData}, + {type: TaskEvent.END}, + ]); + expect(webRtc.uiControls.wrapup.isVisible).toBe(true); + expect(webRtc.uiControls.mute.isVisible).toBe(false); + }); - it('setUIControls for AGENT_CONTACT_HELD disables mute', () => { - webRtc.updateTaskData({ ...data, type: CC_EVENTS.AGENT_CONTACT_HELD }); - expect(webRtc.taskUiControls.mute.visible).toBe(true); - expect(webRtc.taskUiControls.mute.enabled).toBe(false); - }); + it('setUIControls for AGENT_CONTACT_HELD disables mute', () => { + sendStateEvents(webRtc, [ + {type: TaskEvent.OFFER, taskData}, + {type: TaskEvent.ASSIGN, taskData}, + {type: TaskEvent.HOLD, mediaResourceId: taskData.mediaResourceId}, + {type: TaskEvent.HOLD_SUCCESS, mediaResourceId: taskData.mediaResourceId}, + ]); + expect(webRtc.uiControls.mute.isVisible).toBe(false); + expect(webRtc.uiControls.mute.isEnabled).toBe(false); + }); - it('setUIControls for AGENT_CONTACT_UNHELD re-enables mute', () => { - webRtc.updateTaskData({ ...data, type: CC_EVENTS.AGENT_CONTACT_UNHELD }); - expect(webRtc.taskUiControls.mute.visible).toBe(true); - expect(webRtc.taskUiControls.mute.enabled).toBe(true); - }); + it('setUIControls for AGENT_CONTACT_UNHELD re-enables mute', () => { + sendStateEvents(webRtc, [ + {type: TaskEvent.OFFER, taskData}, + {type: TaskEvent.ASSIGN, taskData}, + {type: TaskEvent.HOLD, mediaResourceId: taskData.mediaResourceId}, + {type: TaskEvent.HOLD_SUCCESS, mediaResourceId: taskData.mediaResourceId}, + {type: TaskEvent.UNHOLD, mediaResourceId: taskData.mediaResourceId}, + {type: TaskEvent.UNHOLD_SUCCESS, mediaResourceId: taskData.mediaResourceId}, + ]); + expect(webRtc.uiControls.mute.isVisible).toBe(true); + expect(webRtc.uiControls.mute.isEnabled).toBe(true); + }); - it('setUIControls for AGENT_OFFER_CONTACT shows accept and decline', () => { - webRtc.updateTaskData({ ...data, type: CC_EVENTS.AGENT_OFFER_CONTACT }); - expect(webRtc.taskUiControls.accept.visible).toBe(true); - expect(webRtc.taskUiControls.decline.visible).toBe(true); - }); + it('setUIControls for AGENT_OFFER_CONTACT shows accept and decline', () => { + sendStateEvents(webRtc, [{type: TaskEvent.OFFER, taskData}]); + expect(webRtc.uiControls.accept.isVisible).toBe(true); + expect(webRtc.uiControls.decline.isVisible).toBe(true); + }); - it('setUIControls for AGENT_OFFER_CONSULT shows accept and decline', () => { - webRtc.updateTaskData({ ...data, type: CC_EVENTS.AGENT_OFFER_CONSULT }); - expect(webRtc.taskUiControls.accept.visible).toBe(true); - expect(webRtc.taskUiControls.decline.visible).toBe(true); - }); + it('setUIControls for AGENT_OFFER_CONSULT shows accept and decline', () => { + sendStateEvents(webRtc, [{type: TaskEvent.OFFER_CONSULT, taskData}]); + expect(webRtc.uiControls.accept.isVisible).toBe(true); + expect(webRtc.uiControls.decline.isVisible).toBe(true); + }); - it('setUIControls for AGENT_CONSULTING hides accept/decline and shows mute when consulted', () => { - webRtc.updateTaskData({ ...data, type: CC_EVENTS.AGENT_CONSULTING, isConsulted: true }); - expect(webRtc.taskUiControls.accept.visible).toBe(false); - expect(webRtc.taskUiControls.decline.visible).toBe(false); - expect(webRtc.taskUiControls.mute.visible).toBe(true); - expect(webRtc.taskUiControls.mute.enabled).toBe(true); - }); + it('setUIControls for AGENT_CONSULTING hides accept/decline and shows mute when consulted', () => { + webRtc.updateTaskData({...taskData, isConsulted: true}); + sendStateEvents(webRtc, [ + {type: TaskEvent.OFFER_CONSULT, taskData}, + {type: TaskEvent.ACCEPT}, + ]); + expect(webRtc.uiControls.accept.isVisible).toBe(false); + expect(webRtc.uiControls.decline.isVisible).toBe(false); + expect(webRtc.uiControls.mute.isVisible).toBe(true); + expect(webRtc.uiControls.mute.isEnabled).toBe(true); + }); - it('setUIControls for AGENT_CONSULT_ENDED hides mute when consulted', () => { - webRtc.updateTaskData({ ...data, type: CC_EVENTS.AGENT_CONSULT_ENDED, isConsulted: true }); - expect(webRtc.taskUiControls.mute.visible).toBe(false); - }); + it('setUIControls for AGENT_CONSULT_ENDED returns mute to connected state behavior', () => { + webRtc.updateTaskData({...taskData, isConsulted: true}); + sendStateEvents(webRtc, [ + {type: TaskEvent.OFFER_CONSULT, taskData}, + {type: TaskEvent.ACCEPT}, + {type: TaskEvent.CONSULT_END}, + ]); + expect(webRtc.uiControls.mute.isVisible).toBe(true); + }); - it('setUIControls for AGENT_CONTACT_OFFER_RONA hides accept and decline', () => { - webRtc.updateTaskData({ ...data, type: CC_EVENTS.AGENT_CONTACT_OFFER_RONA }); - expect(webRtc.taskUiControls.accept.visible).toBe(false); - expect(webRtc.taskUiControls.decline.visible).toBe(false); - }); + it('setUIControls for AGENT_CONTACT_OFFER_RONA hides accept and decline', () => { + sendStateEvents(webRtc, [ + {type: TaskEvent.OFFER, taskData}, + {type: TaskEvent.RONA}, + ]); + expect(webRtc.uiControls.accept.isVisible).toBe(false); + expect(webRtc.uiControls.decline.isVisible).toBe(false); + }); }); }); diff --git a/packages/@webex/webex-core/src/index.js b/packages/@webex/webex-core/src/index.js index 71188f923e3..b6bfb13fde1 100644 --- a/packages/@webex/webex-core/src/index.js +++ b/packages/@webex/webex-core/src/index.js @@ -24,17 +24,8 @@ export { ServiceUrl, } from './lib/services'; +export {ServiceCatalogV2, ServicesV2, ServiceDetail} from './lib/services-v2'; export * as serviceConstants from './lib/constants'; -export { - constants as serviceConstantsV2, - ServiceCatalogV2, - ServiceInterceptorV2, - ServerErrorInterceptorV2, - ServicesV2, - ServiceUrlV2, - HostMapInterceptorV2, -} from './lib/services-v2'; - export { makeWebexStore, makeWebexPluginStore, diff --git a/packages/@webex/webex-core/src/lib/services-v2/index.js b/packages/@webex/webex-core/src/lib/services-v2/index.js index 36a0997e6c9..602ebaced6f 100644 --- a/packages/@webex/webex-core/src/lib/services-v2/index.js +++ b/packages/@webex/webex-core/src/lib/services-v2/index.js @@ -21,3 +21,4 @@ export {default as ServerErrorInterceptorV2} from './interceptors/server-error'; export {default as HostMapInterceptorV2} from './interceptors/hostmap'; export {default as ServiceCatalogV2} from './service-catalog'; export {default as ServiceUrlV2} from './service-url'; +export {default as ServiceDetail} from './service-detail'; From 08b3c610292e19c2d1d5da8e054bd930041c9f46 Mon Sep 17 00:00:00 2001 From: arungane Date: Tue, 2 Dec 2025 16:05:08 +0530 Subject: [PATCH 12/14] fix: update with review comments --- packages/@webex/contact-center/package.json | 1 + packages/@webex/contact-center/src/cc.ts | 8 +- .../src/services/config/Util.ts | 2 +- .../src/services/config/types.ts | 4 +- .../contact-center/src/services/task/Task.ts | 27 +- .../src/services/task/TaskFactory.ts | 4 +- .../src/services/task/digital/Digital.ts | 8 +- .../task/state-machine/TaskStateMachine.ts | 3 +- .../services/task/state-machine/actions.ts | 9 +- .../services/task/state-machine/constants.ts | 122 ++++++++ .../src/services/task/state-machine/index.ts | 6 +- .../src/services/task/state-machine/types.ts | 280 ++++++------------ .../task/state-machine/uiControlsComputer.ts | 66 ++--- .../src/services/task/taskDataNormalizer.ts | 46 ++- .../contact-center/src/services/task/types.ts | 118 +++++++- .../src/services/task/voice/Voice.ts | 18 +- .../src/services/task/voice/WebRTC.ts | 13 +- packages/@webex/contact-center/src/types.ts | 2 +- .../contact-center/test/unit/spec/cc.ts | 6 +- .../test/unit/spec/services/task/Task.ts | 2 +- .../unit/spec/services/task/TaskFactory.ts | 2 +- .../unit/spec/services/task/TaskManager.ts | 2 +- .../task/state-machine/TaskStateMachine.ts | 2 +- .../unit/spec/services/task/voice/Voice.ts | 18 +- .../unit/spec/services/task/voice/WebRTC.ts | 4 +- 25 files changed, 435 insertions(+), 338 deletions(-) create mode 100644 packages/@webex/contact-center/src/services/task/state-machine/constants.ts diff --git a/packages/@webex/contact-center/package.json b/packages/@webex/contact-center/package.json index d7a619065a9..86710fafcce 100644 --- a/packages/@webex/contact-center/package.json +++ b/packages/@webex/contact-center/package.json @@ -54,6 +54,7 @@ "@webex/webex-core": "workspace:*", "jest-html-reporters": "3.0.11", "lodash": "^4.17.21", + "uuid": "^3.3.2", "xstate": "5.24.0" }, "devDependencies": { diff --git a/packages/@webex/contact-center/src/cc.ts b/packages/@webex/contact-center/src/cc.ts index 69a132cf1c3..0654e11d88a 100644 --- a/packages/@webex/contact-center/src/cc.ts +++ b/packages/@webex/contact-center/src/cc.ts @@ -60,9 +60,9 @@ import {ITask, TASK_EVENTS, TaskResponse, DialerPayload} from './services/task/t import MetricsManager from './metrics/MetricsManager'; import {METRIC_EVENT_NAMES} from './metrics/constants'; import {Failure} from './services/core/GlobalTypes'; -import EntryPoint from './services/EntryPoint'; -import AddressBook from './services/AddressBook'; -import Queue from './services/Queue'; +import {EntryPoint} from './services/EntryPoint'; +import {AddressBook} from './services/AddressBook'; +import {Queue} from './services/Queue'; import type { EntryPointListResponse, EntryPointSearchParams, @@ -693,7 +693,7 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter const orgId = this.$webex.credentials.getOrgId(); this.agentConfig = await this.services.config.getAgentConfig(orgId, agentId); const configFlags: ConfigFlags = { - isEndCallEnabled: this.agentConfig.isEndCallEnabled, + isEndTaskEnabled: this.agentConfig.isEndTaskEnabled, isEndConsultEnabled: this.agentConfig.isEndConsultEnabled, webRtcEnabled: this.agentConfig.webRtcEnabled, autoWrapup: this.agentConfig.wrapUpData?.wrapUpProps?.autoWrapup ?? false, diff --git a/packages/@webex/contact-center/src/services/config/Util.ts b/packages/@webex/contact-center/src/services/config/Util.ts index 3c1658483a3..43b072637c5 100644 --- a/packages/@webex/contact-center/src/services/config/Util.ts +++ b/packages/@webex/contact-center/src/services/config/Util.ts @@ -226,7 +226,7 @@ function parseAgentConfigs(profileData: { isAgentAvailableAfterOutdial: agentProfileData.agentAvailableAfterOutdial, outDialEp: agentProfileData.outdialEntryPointId, isCampaignManagementEnabled: orgSettingsData.campaignManagerEnabled, - isEndCallEnabled: tenantData.endCallEnabled, + isEndTaskEnabled: tenantData.endCallEnabled, isEndConsultEnabled: tenantData.endConsultEnabled, callVariablesSuppressed: tenantData.callVariablesSuppressed, agentDbId: userData.dbId, diff --git a/packages/@webex/contact-center/src/services/config/types.ts b/packages/@webex/contact-center/src/services/config/types.ts index 0d5edfc5459..97ba6ff5e22 100644 --- a/packages/@webex/contact-center/src/services/config/types.ts +++ b/packages/@webex/contact-center/src/services/config/types.ts @@ -492,7 +492,7 @@ export type DesktopProfileResponse = { /** * Threshold rules configured for the agent profile. */ - thresholdRules: Array>; + thresholdRules: Array>; /** * Whether the agent profile is currently active. @@ -1076,7 +1076,7 @@ export type Profile = { /** Outbound entry point */ outDialEp: string; /** Whether ending calls is enabled */ - isEndCallEnabled: boolean; + isEndTaskEnabled: boolean; /** Whether ending consultations is enabled */ isEndConsultEnabled: boolean; /** Optional lifecycle manager URL */ diff --git a/packages/@webex/contact-center/src/services/task/Task.ts b/packages/@webex/contact-center/src/services/task/Task.ts index b0a8f5581bc..3e843eebf20 100644 --- a/packages/@webex/contact-center/src/services/task/Task.ts +++ b/packages/@webex/contact-center/src/services/task/Task.ts @@ -18,13 +18,12 @@ import routingContact from './contact'; import MetricsManager from '../../metrics/MetricsManager'; import {METRIC_EVENT_NAMES} from '../../metrics/constants'; import LoggerProxy from '../../logger-proxy'; -import { - createTaskStateMachine, - TaskState, +import {createTaskStateMachine, TaskState} from './state-machine'; +import type { TaskEventPayload, - type TaskStateMachine, - type UIControlConfig, - type TaskContext, + TaskStateMachine, + UIControlConfig, + TaskContext, } from './state-machine'; import AutoWrapup from './AutoWrapup'; import { @@ -33,20 +32,6 @@ import { haveUIControlsChanged, } from './state-machine/uiControlsComputer'; -/** - * Participant information for UI display - */ -export type Participant = { - id: string; - name?: string; - pType?: string; -}; - -/** - * @deprecated Use Participant instead - */ -export type TaskAccessorParticipant = Participant; - type CallId = string; export default abstract class Task extends EventEmitter implements ITask { @@ -56,7 +41,7 @@ export default abstract class Task extends EventEmitter implements ITask { public data: TaskData; public webCallMap: Record; public state?: SnapshotFrom; - private lastState: TaskState | null = null; + private lastState?: TaskState; protected currentUiControls: TaskUIControls; protected uiControlConfig: UIControlConfig; diff --git a/packages/@webex/contact-center/src/services/task/TaskFactory.ts b/packages/@webex/contact-center/src/services/task/TaskFactory.ts index a755da19335..18e9780640a 100644 --- a/packages/@webex/contact-center/src/services/task/TaskFactory.ts +++ b/packages/@webex/contact-center/src/services/task/TaskFactory.ts @@ -18,10 +18,10 @@ export default class TaskFactory { configFlags: ConfigFlags ): Task { const mediaType = data.interaction.mediaType ?? MEDIA_CHANNEL.TELEPHONY; - const {isEndCallEnabled, isEndConsultEnabled} = configFlags; + const {isEndTaskEnabled, isEndConsultEnabled} = configFlags; const recordingEnabled = data?.interaction?.callProcessingDetails?.pauseResumeEnabled ?? true; const voiceControlOptions = { - isEndCallEnabled, + isEndTaskEnabled, isEndConsultEnabled, isRecordingEnabled: recordingEnabled, }; diff --git a/packages/@webex/contact-center/src/services/task/digital/Digital.ts b/packages/@webex/contact-center/src/services/task/digital/Digital.ts index 7e58ed02910..b65d7aa3ec8 100644 --- a/packages/@webex/contact-center/src/services/task/digital/Digital.ts +++ b/packages/@webex/contact-center/src/services/task/digital/Digital.ts @@ -1,7 +1,7 @@ import {CC_FILE, METHODS} from '../../../constants'; import {getErrorDetails} from '../../core/Utils'; import routingContact from '../contact'; -import {IDigital, TaskResponse, TaskData} from '../types'; +import {IDigital, TaskResponse, TaskData, TASK_CHANNEL_TYPE} from '../types'; import Task from '../Task'; import LoggerProxy from '../../../logger-proxy'; import MetricsManager from '../../../metrics/MetricsManager'; @@ -10,15 +10,15 @@ import {METRIC_EVENT_NAMES} from '../../../metrics/constants'; export default class Digital extends Task implements IDigital { constructor(contact: ReturnType, data: TaskData) { super(contact, data, { - channelType: 'digital', - isEndCallEnabled: true, + channelType: TASK_CHANNEL_TYPE.DIGITAL, + isEndTaskEnabled: true, isEndConsultEnabled: false, isRecordingEnabled: false, }); } /** - * Compute UI controls based on state machine state for digital channels. + * Refresh the digital task with the latest backend payload and recompute UI controls. */ public updateTaskData(newData: TaskData, shouldOverwrite = false): IDigital { super.updateTaskData(newData, shouldOverwrite); diff --git a/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts b/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts index eea1e197286..5b1fb16dbd4 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts @@ -6,7 +6,8 @@ */ import {setup} from 'xstate'; -import {TaskState, TaskEvent, TaskContext, TaskEventPayload, UIControlConfig} from './types'; +import {TaskContext, TaskEventPayload, UIControlConfig} from './types'; +import {TaskState, TaskEvent} from './constants'; import {actions, createInitialContext} from './actions'; type TaskActionConfigMap = {[K in keyof typeof actions]: undefined}; diff --git a/packages/@webex/contact-center/src/services/task/state-machine/actions.ts b/packages/@webex/contact-center/src/services/task/state-machine/actions.ts index 9cad8592369..6045cb6ac1c 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/actions.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/actions.ts @@ -7,13 +7,15 @@ * NOTE: These actions are meant to be used within XState assign() or as standalone action functions. * Event emission and UI control updates will be handled by the Task/Voice classes that use this state machine. * - * TODO: Event emission logic will be integrated with existing Task EventEmitter pattern. - * TODO: Resource cleanup logic will be added to handle WebRTC and other resources. + * Side effects such as emitting Task events or cleaning up WebRTC resources should stay in the + * consumer classes (Task/Voice) by extending the action map passed into the machine. Keeping these + * core actions pure makes the state machine predictable and easy to reason about. */ import {assign} from 'xstate'; import type {ActionFunctionMap, EventObject} from 'xstate'; -import {TaskContext, TaskEventPayload, TaskEvent, UIControlConfig, TaskState} from './types'; +import {TaskContext, TaskEventPayload, UIControlConfig} from './types'; +import {TaskEvent, TaskState} from './constants'; import {TaskData} from '../types'; import {computeUIControls, getDefaultUIControls} from './uiControlsComputer'; @@ -45,6 +47,7 @@ const deriveRecordingState = (taskData?: TaskData | null): RecordingStateUpdate isPaused?: boolean; }; + // Recording availability toggles when backend explicitly tells if the feature is on if (recordingStarted !== undefined) { update.recordingControlsAvailable = recordingStarted; if (!recordingStarted) { diff --git a/packages/@webex/contact-center/src/services/task/state-machine/constants.ts b/packages/@webex/contact-center/src/services/task/state-machine/constants.ts new file mode 100644 index 00000000000..44ef48fb90c --- /dev/null +++ b/packages/@webex/contact-center/src/services/task/state-machine/constants.ts @@ -0,0 +1,122 @@ +/** + * Constants for the task state machine. + * These enums define the allowed states, events, and built-in action identifiers. + */ + +export enum TaskState { + IDLE = 'IDLE', + OFFERED = 'OFFERED', + OFFERED_CONSULT = 'OFFERED_CONSULT', + CONNECTED = 'CONNECTED', + + // Intermediate states for async operations + HOLD_INITIATING = 'HOLD_INITIATING', + HELD = 'HELD', + RESUME_INITIATING = 'RESUME_INITIATING', + + CONSULT_INITIATING = 'CONSULT_INITIATING', + CONSULTING = 'CONSULTING', + + CONFERENCING = 'CONFERENCING', + WRAPPING_UP = 'WRAPPING_UP', + COMPLETED = 'COMPLETED', + TERMINATED = 'TERMINATED', + + // NOT IMPLEMENTED: MPC (Multi-Party Conference) states + CONSULT_INITIATED = 'CONSULT_INITIATED', + CONSULT_COMPLETED = 'CONSULT_COMPLETED', + // NOT IMPLEMENTED: Post-call state (isWxccPostCallEnabled feature flag) + POST_CALL = 'POST_CALL', + // NOT IMPLEMENTED: Parked state + PARKED = 'PARKED', + // NOT IMPLEMENTED: Monitoring/Supervisory states + MONITORING = 'MONITORING', +} + +export enum TaskEvent { + // Offer events + OFFER = 'OFFER', + OFFER_CONSULT = 'OFFER_CONSULT', + + // Assignment events + ACCEPT = 'ACCEPT', + DECLINE = 'DECLINE', + ASSIGN = 'ASSIGN', + + // Hold/Resume events + HOLD = 'HOLD', + HOLD_SUCCESS = 'HOLD_SUCCESS', + HOLD_FAILED = 'HOLD_FAILED', + UNHOLD = 'UNHOLD', + UNHOLD_SUCCESS = 'UNHOLD_SUCCESS', + UNHOLD_FAILED = 'UNHOLD_FAILED', + + // Consult events + CONSULT = 'CONSULT', + CONSULT_SUCCESS = 'CONSULT_SUCCESS', + CONSULT_CREATED = 'CONSULT_CREATED', + CONSULTING_ACTIVE = 'CONSULTING_ACTIVE', + CONSULT_END = 'CONSULT_END', + CONSULT_TRANSFER = 'CONSULT_TRANSFER', + CONSULT_FAILED = 'CONSULT_FAILED', + + // Conference events + START_CONFERENCE = 'START_CONFERENCE', + MERGE_TO_CONFERENCE = 'MERGE_TO_CONFERENCE', + CONFERENCE_START = 'CONFERENCE_START', + CONFERENCE_END = 'CONFERENCE_END', + TRANSFER_CONFERENCE = 'TRANSFER_CONFERENCE', + PARTICIPANT_JOIN = 'PARTICIPANT_JOIN', + PARTICIPANT_LEAVE = 'PARTICIPANT_LEAVE', + EXIT_CONFERENCE = 'EXIT_CONFERENCE', + + // Recording events + RECORDING_STARTED = 'RECORDING_STARTED', + PAUSE_RECORDING = 'PAUSE_RECORDING', + RESUME_RECORDING = 'RESUME_RECORDING', + + // Transfer events + TRANSFER = 'TRANSFER', + + // Wrapup events + WRAPUP_START = 'WRAPUP_START', + WRAPUP = 'WRAPUP', + WRAPUP_COMPLETE = 'WRAPUP_COMPLETE', + + // End events + END = 'END', + RONA = 'RONA', // Ring On No Answer + CONTACT_ENDED = 'CONTACT_ENDED', + AUTO_WRAPUP = 'AUTO_WRAPUP', + + // Failure events + ASSIGN_FAILED = 'ASSIGN_FAILED', + INVITE_FAILED = 'INVITE_FAILED', + + // Queue events + CTQ_CANCEL = 'CTQ_CANCEL', // Cancel To Queue +} + +export enum TaskAction { + // Entry/Exit actions + INITIALIZE_TASK = 'initializeTask', + EMIT_TASK_INCOMING = 'emitTaskIncoming', + EMIT_TASK_ASSIGNED = 'emitTaskAssigned', + EMIT_TASK_HOLD = 'emitTaskHold', + EMIT_TASK_RESUME = 'emitTaskResume', + EMIT_TASK_CONSULT_CREATED = 'emitTaskConsultCreated', + EMIT_TASK_CONSULTING = 'emitTaskConsulting', + EMIT_TASK_CONSULT_END = 'emitTaskConsultEnd', + EMIT_TASK_END = 'emitTaskEnd', + EMIT_TASK_WRAPPEDUP = 'emitTaskWrappedup', + CLEANUP_RESOURCES = 'cleanupResources', + + // Context updates + UPDATE_TASK_DATA = 'updateTaskData', + SET_CONSULT_INITIATOR = 'setConsultInitiator', + SET_CONSULT_DESTINATION = 'setConsultDestination', + SET_CONSULT_AGENT_JOINED = 'setConsultAgentJoined', + SET_HOLD_STATE = 'setHoldState', + SET_RECORDING_STATE = 'setRecordingState', + UPDATE_TIMESTAMP = 'updateTimestamp', +} diff --git a/packages/@webex/contact-center/src/services/task/state-machine/index.ts b/packages/@webex/contact-center/src/services/task/state-machine/index.ts index dc8563d78ad..6f54e4d6136 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/index.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/index.ts @@ -8,8 +8,9 @@ export {getTaskStateMachineConfig, createTaskStateMachine} from './TaskStateMachine'; export type {TaskStateMachine} from './TaskStateMachine'; -// Types -export {TaskState, TaskEvent, isEventOfType} from './types'; +// Types & enums +export {TaskState, TaskEvent, TaskAction} from './constants'; +export {isEventOfType} from './types'; export type { TaskContext, TaskEventPayload, @@ -21,7 +22,6 @@ export type { // Guards export {guards} from './guards'; export type {GuardParams, GuardFunction} from './guards'; -export type {TaskAction} from './types'; // Actions export {actions, createInitialContext} from './actions'; diff --git a/packages/@webex/contact-center/src/services/task/state-machine/types.ts b/packages/@webex/contact-center/src/services/task/state-machine/types.ts index 9ca1746d137..d644e779c29 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/types.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/types.ts @@ -5,107 +5,8 @@ * These types define states, events, context, and schemas for task lifecycle management. */ -import {TaskData, TaskUIControls} from '../types'; - -/** - * All possible states in the task state machine - */ -export enum TaskState { - IDLE = 'IDLE', - OFFERED = 'OFFERED', - OFFERED_CONSULT = 'OFFERED_CONSULT', - CONNECTED = 'CONNECTED', - - // Intermediate states for async operations - HOLD_INITIATING = 'HOLD_INITIATING', - HELD = 'HELD', - RESUME_INITIATING = 'RESUME_INITIATING', - - CONSULT_INITIATING = 'CONSULT_INITIATING', - CONSULTING = 'CONSULTING', - - CONFERENCING = 'CONFERENCING', - WRAPPING_UP = 'WRAPPING_UP', - COMPLETED = 'COMPLETED', - TERMINATED = 'TERMINATED', - - // NOT IMPLEMENTED: MPC (Multi-Party Conference) states - CONSULT_INITIATED = 'CONSULT_INITIATED', - CONSULT_COMPLETED = 'CONSULT_COMPLETED', - // NOT IMPLEMENTED: Post-call state (isWxccPostCallEnabled feature flag) - POST_CALL = 'POST_CALL', - // NOT IMPLEMENTED: Parked state - PARKED = 'PARKED', - // NOT IMPLEMENTED: Monitoring/Supervisory states - MONITORING = 'MONITORING', -} - -/** - * All possible events that can trigger state transitions - */ -export enum TaskEvent { - // Offer events - OFFER = 'OFFER', - OFFER_CONSULT = 'OFFER_CONSULT', - - // Assignment events - ACCEPT = 'ACCEPT', - DECLINE = 'DECLINE', - ASSIGN = 'ASSIGN', - - // Hold/Resume events - HOLD = 'HOLD', - HOLD_SUCCESS = 'HOLD_SUCCESS', - HOLD_FAILED = 'HOLD_FAILED', - UNHOLD = 'UNHOLD', - UNHOLD_SUCCESS = 'UNHOLD_SUCCESS', - UNHOLD_FAILED = 'UNHOLD_FAILED', - - // Consult events - CONSULT = 'CONSULT', - CONSULT_SUCCESS = 'CONSULT_SUCCESS', - CONSULT_CREATED = 'CONSULT_CREATED', - CONSULTING_ACTIVE = 'CONSULTING_ACTIVE', - CONSULT_END = 'CONSULT_END', - CONSULT_TRANSFER = 'CONSULT_TRANSFER', - CONSULT_FAILED = 'CONSULT_FAILED', - - // Conference events - START_CONFERENCE = 'START_CONFERENCE', - MERGE_TO_CONFERENCE = 'MERGE_TO_CONFERENCE', - CONFERENCE_START = 'CONFERENCE_START', - CONFERENCE_END = 'CONFERENCE_END', - TRANSFER_CONFERENCE = 'TRANSFER_CONFERENCE', - PARTICIPANT_JOIN = 'PARTICIPANT_JOIN', - PARTICIPANT_LEAVE = 'PARTICIPANT_LEAVE', - EXIT_CONFERENCE = 'EXIT_CONFERENCE', - - // Recording events - RECORDING_STARTED = 'RECORDING_STARTED', - PAUSE_RECORDING = 'PAUSE_RECORDING', - RESUME_RECORDING = 'RESUME_RECORDING', - - // Transfer events - TRANSFER = 'TRANSFER', - - // Wrapup events - WRAPUP_START = 'WRAPUP_START', - WRAPUP = 'WRAPUP', - WRAPUP_COMPLETE = 'WRAPUP_COMPLETE', - - // End events - END = 'END', - RONA = 'RONA', // Ring On No Answer - CONTACT_ENDED = 'CONTACT_ENDED', - AUTO_WRAPUP = 'AUTO_WRAPUP', - - // Failure events - ASSIGN_FAILED = 'ASSIGN_FAILED', - INVITE_FAILED = 'INVITE_FAILED', - - // Queue events - CTQ_CANCEL = 'CTQ_CANCEL', // Cancel To Queue -} +import {DestinationType, TaskChannelType, TaskData, TaskUIControls, VoiceVariant} from '../types'; +import {TaskEvent, TaskState} from './constants'; /** * Represents a participant in a conference call @@ -130,17 +31,23 @@ export interface ConferenceParticipant { */ export interface UIControlConfig { /** Whether end call button is enabled (config option) */ - isEndCallEnabled: boolean; + isEndTaskEnabled: boolean; /** Whether end consult button is enabled (config option) */ isEndConsultEnabled: boolean; /** Channel type determines which controls are available */ - channelType: 'voice' | 'digital'; + channelType: TaskChannelType; /** Optional voice channel variant to toggle WebRTC-specific controls */ - voiceVariant?: 'pstn' | 'webrtc'; + voiceVariant?: VoiceVariant; /** Whether recording controls should be shown for this task */ isRecordingEnabled: boolean; } +/** + * UI Control states derived from state machine. Reuse the Task UI controls surface shape + * so computed state can flow directly to Task consumers without additional mapping. + */ +export type UIControls = TaskUIControls; + /** * Context data maintained by the state machine * @@ -175,57 +82,79 @@ export interface TaskContext { uiControlConfig: UIControlConfig; // Computed UI controls (derived from state + context + config) - uiControls: TaskUIControls; + uiControls: UIControls; +} + +/** + * Base event type - all events have a type property + */ +type BaseEvent = {type: T}; + +/** + * Event payload mapping - defines the payload for each event type + */ +interface TaskEventPayloadMap { + [TaskEvent.OFFER]: BaseEvent & {taskData: TaskData}; + [TaskEvent.OFFER_CONSULT]: BaseEvent & {taskData: TaskData}; + [TaskEvent.ACCEPT]: BaseEvent; + [TaskEvent.DECLINE]: BaseEvent; + [TaskEvent.ASSIGN]: BaseEvent & {taskData: TaskData}; + [TaskEvent.HOLD]: BaseEvent & {mediaResourceId: string}; + [TaskEvent.HOLD_SUCCESS]: BaseEvent & {mediaResourceId: string}; + [TaskEvent.HOLD_FAILED]: BaseEvent & { + reason?: string; + mediaResourceId: string; + }; + [TaskEvent.UNHOLD]: BaseEvent & {mediaResourceId: string}; + [TaskEvent.UNHOLD_SUCCESS]: BaseEvent & {mediaResourceId: string}; + [TaskEvent.UNHOLD_FAILED]: BaseEvent & { + reason?: string; + mediaResourceId: string; + }; + [TaskEvent.CONSULT]: BaseEvent & { + destination: string; + destinationType: DestinationType; + }; + [TaskEvent.CONSULT_SUCCESS]: BaseEvent & {taskData?: TaskData}; + [TaskEvent.CONSULT_CREATED]: BaseEvent & {taskData: TaskData}; + [TaskEvent.CONSULTING_ACTIVE]: BaseEvent & { + consultDestinationAgentJoined: boolean; + }; + [TaskEvent.CONSULT_END]: BaseEvent; + [TaskEvent.CONSULT_TRANSFER]: BaseEvent; + [TaskEvent.CONSULT_FAILED]: BaseEvent & {reason?: string}; + [TaskEvent.START_CONFERENCE]: BaseEvent; + [TaskEvent.MERGE_TO_CONFERENCE]: BaseEvent; + [TaskEvent.CONFERENCE_START]: BaseEvent & { + participants?: ConferenceParticipant[]; + }; + [TaskEvent.CONFERENCE_END]: BaseEvent; + [TaskEvent.TRANSFER_CONFERENCE]: BaseEvent & {agentId?: string}; + [TaskEvent.PARTICIPANT_JOIN]: BaseEvent & { + participant: ConferenceParticipant; + }; + [TaskEvent.PARTICIPANT_LEAVE]: BaseEvent & {participantId: string}; + [TaskEvent.EXIT_CONFERENCE]: BaseEvent & {agentId?: string}; + [TaskEvent.RECORDING_STARTED]: BaseEvent & {taskData: TaskData}; + [TaskEvent.PAUSE_RECORDING]: BaseEvent; + [TaskEvent.RESUME_RECORDING]: BaseEvent; + [TaskEvent.TRANSFER]: BaseEvent; + [TaskEvent.WRAPUP_START]: BaseEvent; + [TaskEvent.WRAPUP]: BaseEvent & {wrapupData?: any}; + [TaskEvent.WRAPUP_COMPLETE]: BaseEvent; + [TaskEvent.END]: BaseEvent; + [TaskEvent.RONA]: BaseEvent; + [TaskEvent.CONTACT_ENDED]: BaseEvent; + [TaskEvent.AUTO_WRAPUP]: BaseEvent; + [TaskEvent.ASSIGN_FAILED]: BaseEvent & {reason?: string}; + [TaskEvent.INVITE_FAILED]: BaseEvent & {reason?: string}; + [TaskEvent.CTQ_CANCEL]: BaseEvent; } /** - * Event payload types for each event + * Union of all possible event payloads */ -export type TaskEventPayload = - | {type: TaskEvent.OFFER; taskData: TaskData} - | {type: TaskEvent.OFFER_CONSULT; taskData: TaskData} - | {type: TaskEvent.ACCEPT} - | {type: TaskEvent.DECLINE} - | {type: TaskEvent.ASSIGN; taskData: TaskData} - | {type: TaskEvent.HOLD; mediaResourceId: string} - | {type: TaskEvent.HOLD_SUCCESS; mediaResourceId: string} - | {type: TaskEvent.HOLD_FAILED; reason?: string; mediaResourceId: string} - | {type: TaskEvent.UNHOLD; mediaResourceId: string} - | {type: TaskEvent.UNHOLD_SUCCESS; mediaResourceId: string} - | {type: TaskEvent.UNHOLD_FAILED; reason?: string; mediaResourceId: string} - | { - type: TaskEvent.CONSULT; - destination: string; - destinationType: 'agent' | 'queue' | 'entryPoint'; - } - | {type: TaskEvent.CONSULT_SUCCESS; taskData?: TaskData} - | {type: TaskEvent.CONSULT_CREATED; taskData: TaskData} - | {type: TaskEvent.CONSULTING_ACTIVE; consultDestinationAgentJoined: boolean} - | {type: TaskEvent.CONSULT_END} - | {type: TaskEvent.CONSULT_TRANSFER} - | {type: TaskEvent.CONSULT_FAILED; reason?: string} - | {type: TaskEvent.START_CONFERENCE} - | {type: TaskEvent.MERGE_TO_CONFERENCE} - | {type: TaskEvent.CONFERENCE_START; participants?: ConferenceParticipant[]} - | {type: TaskEvent.CONFERENCE_END} - | {type: TaskEvent.TRANSFER_CONFERENCE; agentId?: string} - | {type: TaskEvent.PARTICIPANT_JOIN; participant: ConferenceParticipant} - | {type: TaskEvent.PARTICIPANT_LEAVE; participantId: string} - | {type: TaskEvent.EXIT_CONFERENCE; agentId?: string} - | {type: TaskEvent.RECORDING_STARTED; taskData: TaskData} - | {type: TaskEvent.PAUSE_RECORDING} - | {type: TaskEvent.RESUME_RECORDING} - | {type: TaskEvent.TRANSFER} - | {type: TaskEvent.WRAPUP_START} - | {type: TaskEvent.WRAPUP; wrapupData?: any} - | {type: TaskEvent.WRAPUP_COMPLETE} - | {type: TaskEvent.END} - | {type: TaskEvent.RONA} - | {type: TaskEvent.CONTACT_ENDED} - | {type: TaskEvent.AUTO_WRAPUP} - | {type: TaskEvent.ASSIGN_FAILED; reason?: string} - | {type: TaskEvent.INVITE_FAILED; reason?: string} - | {type: TaskEvent.CTQ_CANCEL}; +export type TaskEventPayload = TaskEventPayloadMap[TaskEvent]; /** * Type guard to check event type @@ -233,28 +162,16 @@ export type TaskEventPayload = export function isEventOfType( event: TaskEventPayload | undefined, type: T -): event is Extract { +): event is TaskEventPayloadMap[T] { return Boolean(event && event.type === type); } /** - * UI Control states derived from state machine + * Recording control state for UI controls computer */ -export interface UIControls { - accept: {isVisible: boolean; isEnabled: boolean}; - decline: {isVisible: boolean; isEnabled: boolean}; - hold: {isVisible: boolean; isEnabled: boolean; label: 'Hold' | 'Resume'}; - transfer: {isVisible: boolean; isEnabled: boolean}; - consult: {isVisible: boolean; isEnabled: boolean}; - end: {isVisible: boolean; isEnabled: boolean}; - recording: {isVisible: boolean; isEnabled: boolean}; - mute: {isVisible: boolean; isEnabled: boolean}; - consultTransfer: {isVisible: boolean; isEnabled: boolean}; - endConsult: {isVisible: boolean; isEnabled: boolean}; - conference: {isVisible: boolean; isEnabled: boolean}; - exitConference: {isVisible: boolean; isEnabled: boolean}; - transferConference: {isVisible: boolean; isEnabled: boolean}; - wrapup: {isVisible: boolean; isEnabled: boolean}; +export interface RecordingControlState { + available: boolean; + inProgress: boolean; } /** @@ -266,30 +183,3 @@ export interface TaskStateMachineConfig { context: TaskContext; states: Record; } - -/** - * Action types for state machine - */ -export enum TaskAction { - // Entry/Exit actions - INITIALIZE_TASK = 'initializeTask', - EMIT_TASK_INCOMING = 'emitTaskIncoming', - EMIT_TASK_ASSIGNED = 'emitTaskAssigned', - EMIT_TASK_HOLD = 'emitTaskHold', - EMIT_TASK_RESUME = 'emitTaskResume', - EMIT_TASK_CONSULT_CREATED = 'emitTaskConsultCreated', - EMIT_TASK_CONSULTING = 'emitTaskConsulting', - EMIT_TASK_CONSULT_END = 'emitTaskConsultEnd', - EMIT_TASK_END = 'emitTaskEnd', - EMIT_TASK_WRAPPEDUP = 'emitTaskWrappedup', - CLEANUP_RESOURCES = 'cleanupResources', - - // Context updates - UPDATE_TASK_DATA = 'updateTaskData', - SET_CONSULT_INITIATOR = 'setConsultInitiator', - SET_CONSULT_DESTINATION = 'setConsultDestination', - SET_CONSULT_AGENT_JOINED = 'setConsultAgentJoined', - SET_HOLD_STATE = 'setHoldState', - SET_RECORDING_STATE = 'setRecordingState', - UPDATE_TIMESTAMP = 'updateTimestamp', -} diff --git a/packages/@webex/contact-center/src/services/task/state-machine/uiControlsComputer.ts b/packages/@webex/contact-center/src/services/task/state-machine/uiControlsComputer.ts index 4c861e96bb4..b194d83748a 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/uiControlsComputer.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/uiControlsComputer.ts @@ -7,13 +7,14 @@ * - Configuration */ -import {TaskData, TaskUIControls} from '../types'; -import {TaskState, TaskContext, UIControlConfig} from './types'; +import {TASK_CHANNEL_TYPE, TaskData, TaskUIControls, VOICE_VARIANT} from '../types'; +import {RecordingControlState, TaskContext, UIControlConfig} from './types'; +import {TaskState} from './constants'; -type RecordingControlState = { - available: boolean; - inProgress: boolean; -}; +/** + * Constant for a disabled/hidden control state + */ +const DISABLED_CONTROL = {isVisible: false, isEnabled: false} as const; function getRecordingControlState(context: TaskContext): RecordingControlState { return { @@ -27,21 +28,21 @@ function getRecordingControlState(context: TaskContext): RecordingControlState { */ export function getDefaultUIControls(): TaskUIControls { return { - accept: {isVisible: false, isEnabled: false}, - decline: {isVisible: false, isEnabled: false}, - hold: {isVisible: false, isEnabled: false}, - mute: {isVisible: false, isEnabled: false}, - end: {isVisible: false, isEnabled: false}, - transfer: {isVisible: false, isEnabled: false}, - consult: {isVisible: false, isEnabled: false}, - consultTransfer: {isVisible: false, isEnabled: false}, - endConsult: {isVisible: false, isEnabled: false}, - recording: {isVisible: false, isEnabled: false}, - conference: {isVisible: false, isEnabled: false}, - wrapup: {isVisible: false, isEnabled: false}, - exitConference: {isVisible: false, isEnabled: false}, - transferConference: {isVisible: false, isEnabled: false}, - mergeToConference: {isVisible: false, isEnabled: false}, + accept: DISABLED_CONTROL, + decline: DISABLED_CONTROL, + hold: DISABLED_CONTROL, + mute: DISABLED_CONTROL, + end: DISABLED_CONTROL, + transfer: DISABLED_CONTROL, + consult: DISABLED_CONTROL, + consultTransfer: DISABLED_CONTROL, + endConsult: DISABLED_CONTROL, + recording: DISABLED_CONTROL, + conference: DISABLED_CONTROL, + wrapup: DISABLED_CONTROL, + exitConference: DISABLED_CONTROL, + transferConference: DISABLED_CONTROL, + mergeToConference: DISABLED_CONTROL, }; } @@ -54,7 +55,7 @@ function computeVoiceUIControls( config: UIControlConfig, fallbackTaskData?: TaskData ): TaskUIControls { - const isWebrtc = config.voiceVariant === 'webrtc'; + const isWebrtc = config.voiceVariant === VOICE_VARIANT.WEBRTC; const isOffered = currentState === TaskState.OFFERED || currentState === TaskState.OFFERED_CONSULT; const isConnected = currentState === TaskState.CONNECTED; @@ -67,7 +68,8 @@ function computeVoiceUIControls( const isTerminated = taskData?.interaction?.isTerminated ?? false; const {available: recordingAvailable, inProgress: recordingInProgress} = getRecordingControlState(context); - const recordingFeatureEnabled = config.channelType === 'voice' && config.isRecordingEnabled; + const recordingFeatureEnabled = + config.channelType === TASK_CHANNEL_TYPE.VOICE && config.isRecordingEnabled; const shouldShowAcceptDecline = isWebrtc ? isOffered && !isTerminated && (!isConsulting || !isConsultedAgent) : isOffered; @@ -104,7 +106,7 @@ function computeVoiceUIControls( // End button: conditional based on config, disabled when held or wrapping up end: { - isVisible: config.isEndCallEnabled, + isVisible: config.isEndTaskEnabled, isEnabled: !isHeld && !isWrappingUp, }, @@ -296,16 +298,14 @@ export function computeUIControls( ): TaskUIControls { const {uiControlConfig} = context; - // Route to appropriate channel-specific computation - if (uiControlConfig.channelType === 'voice') { - return computeVoiceUIControls(currentState, context, uiControlConfig, fallbackTaskData); + switch (uiControlConfig.channelType) { + case TASK_CHANNEL_TYPE.VOICE: + return computeVoiceUIControls(currentState, context, uiControlConfig, fallbackTaskData); + case TASK_CHANNEL_TYPE.DIGITAL: + return computeDigitalUIControls(currentState, context, fallbackTaskData); + default: + return getDefaultUIControls(); } - if (uiControlConfig.channelType === 'digital') { - return computeDigitalUIControls(currentState, context, fallbackTaskData); - } - - // Fallback to default (all hidden/disabled) - return getDefaultUIControls(); } /** diff --git a/packages/@webex/contact-center/src/services/task/taskDataNormalizer.ts b/packages/@webex/contact-center/src/services/task/taskDataNormalizer.ts index 797a4fba7dd..1f38e9a6922 100644 --- a/packages/@webex/contact-center/src/services/task/taskDataNormalizer.ts +++ b/packages/@webex/contact-center/src/services/task/taskDataNormalizer.ts @@ -1,19 +1,11 @@ -import {TaskData} from './types'; - -type BooleanKey = - | 'recordingStarted' - | 'recordInProgress' - | 'isPaused' - | 'pauseResumeEnabled' - | 'ctqInProgress' - | 'outdialTransferToQueueEnabled' - | 'taskToBeSelfServiced' - | 'CONTINUE_RECORDING_ON_TRANSFER' - | 'isParked' - | 'participantInviteTimeout' - | 'checkAgentAvailability'; - -const booleanKeys: BooleanKey[] = [ +import { + CallProcessingBooleanKey, + InteractionBooleanKey, + ParticipantBooleanKey, + TaskData, +} from './types'; + +const booleanKeys: CallProcessingBooleanKey[] = [ 'recordingStarted', 'recordInProgress', 'isPaused', @@ -27,13 +19,13 @@ const booleanKeys: BooleanKey[] = [ 'checkAgentAvailability', ]; -const interactionBooleanKeys: Array = [ +const interactionBooleanKeys: InteractionBooleanKey[] = [ 'isFcManaged', 'isMediaForked', 'isTerminated', ]; -const participantBooleanKeys = [ +const participantBooleanKeys: ParticipantBooleanKey[] = [ 'autoAnswerEnabled', 'hasJoined', 'hasLeft', @@ -50,10 +42,12 @@ const toBoolean = (value: unknown): boolean | undefined => { } if (typeof value === 'string') { - if (value.toLowerCase() === 'true') { + const normalized = value.toLowerCase(); + + if (normalized === 'true') { return true; } - if (value.toLowerCase() === 'false') { + if (normalized === 'false') { return false; } } @@ -101,22 +95,26 @@ export function normalizeTaskData(data: TaskData): TaskData { ); let updatedParticipants: typeof interaction.participants | undefined; - Object.entries(interaction.participants || {}).forEach(([id, participant]) => { + const participants = interaction.participants || {}; + Object.keys(participants).forEach((id) => { + const participant = participants[id]; const normalized = normalizeFields(participant, participantBooleanKeys); if (normalized) { if (!updatedParticipants) { - updatedParticipants = {...interaction.participants}; + updatedParticipants = {...participants}; } updatedParticipants[id] = normalized; } }); let updatedMedia: typeof interaction.media | undefined; - Object.entries(interaction.media || {}).forEach(([id, media]) => { + const mediaEntries = interaction.media || {}; + Object.keys(mediaEntries).forEach((id) => { + const media = mediaEntries[id]; const normalized = normalizeFields(media, ['isHold']); if (normalized) { if (!updatedMedia) { - updatedMedia = {...interaction.media}; + updatedMedia = {...mediaEntries}; } updatedMedia[id] = normalized; } diff --git a/packages/@webex/contact-center/src/services/task/types.ts b/packages/@webex/contact-center/src/services/task/types.ts index 99c350fff36..bf142bf722c 100644 --- a/packages/@webex/contact-center/src/services/task/types.ts +++ b/packages/@webex/contact-center/src/services/task/types.ts @@ -93,6 +93,26 @@ export const MEDIA_CHANNEL = { */ export type MEDIA_CHANNEL = Enum; +/** + * Supported task channel types for UI control configuration + */ +export const TASK_CHANNEL_TYPE = { + VOICE: 'voice', + DIGITAL: 'digital', +} as const; + +export type TaskChannelType = Enum; + +/** + * Voice channel variants that toggle PSTN/WebRTC specific behaviors + */ +export const VOICE_VARIANT = { + PSTN: 'pstn', + WEBRTC: 'webrtc', +} as const; + +export type VoiceVariant = Enum; + /** * Enumeration of all task-related events that can occur in the contact center system * These events represent different states and actions in the task lifecycle @@ -961,34 +981,49 @@ export type TaskData = { mpcState?: string; }; -/** - * Control state for a single UI action. - */ -export interface TaskUIControlState { +export interface UIControls { + accept: {isVisible: boolean; isEnabled: boolean}; + decline: {isVisible: boolean; isEnabled: boolean}; + hold: {isVisible: boolean; isEnabled: boolean; label: 'Hold' | 'Resume'}; + transfer: {isVisible: boolean; isEnabled: boolean}; + consult: {isVisible: boolean; isEnabled: boolean}; + end: {isVisible: boolean; isEnabled: boolean}; + recording: {isVisible: boolean; isEnabled: boolean}; + mute: {isVisible: boolean; isEnabled: boolean}; + consultTransfer: {isVisible: boolean; isEnabled: boolean}; + endConsult: {isVisible: boolean; isEnabled: boolean}; + conference: {isVisible: boolean; isEnabled: boolean}; + exitConference: {isVisible: boolean; isEnabled: boolean}; + transferConference: {isVisible: boolean; isEnabled: boolean}; + wrapup: {isVisible: boolean; isEnabled: boolean}; +} + +type TaskUIControlState = { isVisible: boolean; isEnabled: boolean; -} +}; /** - * UI control configuration for task operations. + * UI control representation surfaced to task consumers. + * Mirrors the buttons available in Task.uiControls without extra metadata. */ -export interface TaskUIControls { +export type TaskUIControls = { accept: TaskUIControlState; decline: TaskUIControlState; hold: TaskUIControlState; - mute: TaskUIControlState; - end: TaskUIControlState; transfer: TaskUIControlState; consult: TaskUIControlState; + end: TaskUIControlState; + recording: TaskUIControlState; + mute: TaskUIControlState; consultTransfer: TaskUIControlState; endConsult: TaskUIControlState; - recording: TaskUIControlState; conference: TaskUIControlState; - wrapup: TaskUIControlState; exitConference: TaskUIControlState; transferConference: TaskUIControlState; mergeToConference: TaskUIControlState; -} + wrapup: TaskUIControlState; +}; /** * Helper class for managing task action control state @@ -1338,6 +1373,41 @@ export type ContactCleanupData = { }; }; +/** + * Boolean-like fields in callProcessingDetails that may arrive as strings. + * Used by taskDataNormalizer to coerce payloads to actual booleans. + */ +export type CallProcessingBooleanKey = + | 'recordingStarted' + | 'recordInProgress' + | 'isPaused' + | 'pauseResumeEnabled' + | 'ctqInProgress' + | 'outdialTransferToQueueEnabled' + | 'taskToBeSelfServiced' + | 'CONTINUE_RECORDING_ON_TRANSFER' + | 'isParked' + | 'participantInviteTimeout' + | 'checkAgentAvailability'; + +/** + * Interaction-level boolean fields that may arrive as strings from backend payloads. + */ +export type InteractionBooleanKey = 'isFcManaged' | 'isMediaForked' | 'isTerminated'; + +/** + * Participant boolean fields that may arrive as strings and need normalization. + */ +export type ParticipantBooleanKey = + | 'autoAnswerEnabled' + | 'hasJoined' + | 'hasLeft' + | 'isConsulted' + | 'isInPredial' + | 'isOffered' + | 'isWrapUp' + | 'isWrappedUp'; + /** * Response type for task public methods * Can be an {@link AgentContact} object containing updated task state, @@ -1623,6 +1693,30 @@ export interface IVoice extends ITask { holdResume(): Promise; } +/** + * Configuration options for voice task UI controls + */ +export type VoiceUIControlOptions = { + isEndTaskEnabled?: boolean; + isEndConsultEnabled?: boolean; + voiceVariant?: VoiceVariant; + isRecordingEnabled?: boolean; +}; + +/** + * Participant information for UI display + */ +export type Participant = { + id: string; + name?: string; + pType?: string; +}; + +/** + * @deprecated Use Participant instead + */ +export type TaskAccessorParticipant = Participant; + /** * Legacy IOldTask interface for backward compatibility * @deprecated Use ITask, IVoice, or IDigital instead diff --git a/packages/@webex/contact-center/src/services/task/voice/Voice.ts b/packages/@webex/contact-center/src/services/task/voice/Voice.ts index c7ffb15f194..5250a8aa440 100644 --- a/packages/@webex/contact-center/src/services/task/voice/Voice.ts +++ b/packages/@webex/contact-center/src/services/task/voice/Voice.ts @@ -8,9 +8,12 @@ import { TaskData, TaskResponse, IVoice, + VoiceUIControlOptions, TransferPayLoad, ConsultTransferPayLoad, CONSULT_TRANSFER_DESTINATION_TYPE, + TASK_CHANNEL_TYPE, + VOICE_VARIANT, } from '../types'; import Task from '../Task'; import LoggerProxy from '../../../logger-proxy'; @@ -18,13 +21,6 @@ import MetricsManager from '../../../metrics/MetricsManager'; import {METRIC_EVENT_NAMES} from '../../../metrics/constants'; import {TaskState, TaskEvent, guards} from '../state-machine'; -export type VoiceUIControlOptions = { - isEndCallEnabled?: boolean; - isEndConsultEnabled?: boolean; - voiceVariant?: 'pstn' | 'webrtc'; - isRecordingEnabled?: boolean; -}; - export default class Voice extends Task implements IVoice { constructor( contact: ReturnType, @@ -32,10 +28,10 @@ export default class Voice extends Task implements IVoice { callOptions: VoiceUIControlOptions = {} ) { super(contact, data, { - channelType: 'voice', - isEndCallEnabled: callOptions.isEndCallEnabled ?? true, + channelType: TASK_CHANNEL_TYPE.VOICE, + isEndTaskEnabled: callOptions.isEndTaskEnabled ?? true, isEndConsultEnabled: callOptions.isEndConsultEnabled ?? true, - voiceVariant: callOptions.voiceVariant ?? 'pstn', + voiceVariant: callOptions.voiceVariant ?? VOICE_VARIANT.PSTN, isRecordingEnabled: callOptions.isRecordingEnabled ?? true, }); } @@ -414,7 +410,7 @@ export default class Voice extends Task implements IVoice { this.stateMachineService.send({ type: TaskEvent.CONSULT, destination: consultPayload.to, - destinationType: consultPayload.destinationType as 'queue' | 'agent' | 'entryPoint', + destinationType: consultPayload.destinationType, }); } diff --git a/packages/@webex/contact-center/src/services/task/voice/WebRTC.ts b/packages/@webex/contact-center/src/services/task/voice/WebRTC.ts index b2e45b903c7..116b1c1091f 100644 --- a/packages/@webex/contact-center/src/services/task/voice/WebRTC.ts +++ b/packages/@webex/contact-center/src/services/task/voice/WebRTC.ts @@ -2,8 +2,15 @@ import {LocalMicrophoneStream, CALL_EVENT_KEYS} from '@webex/calling'; import {CC_FILE} from '../../../constants'; import {getErrorDetails} from '../../core/Utils'; import routingContact from '../contact'; -import {TaskData, TaskResponse, TASK_EVENTS, IWebRTC} from '../types'; -import Voice, {VoiceUIControlOptions} from './Voice'; +import { + TaskData, + TaskResponse, + TASK_EVENTS, + IWebRTC, + VoiceUIControlOptions, + VOICE_VARIANT, +} from '../types'; +import Voice from './Voice'; import WebCallingService from '../../WebCallingService'; import MetricsManager from '../../../metrics/MetricsManager'; import {METRIC_EVENT_NAMES} from '../../../metrics/constants'; @@ -19,7 +26,7 @@ export default class WebRTC extends Voice implements IWebRTC { data: TaskData, callOptions: VoiceUIControlOptions = {} ) { - super(contact, data, {...callOptions, voiceVariant: 'webrtc'}); + super(contact, data, {...callOptions, voiceVariant: VOICE_VARIANT.WEBRTC}); this.webCallingService = webCallingService; this.registerWebCallListeners(); } diff --git a/packages/@webex/contact-center/src/types.ts b/packages/@webex/contact-center/src/types.ts index 6085682f09c..efb9936f9b5 100644 --- a/packages/@webex/contact-center/src/types.ts +++ b/packages/@webex/contact-center/src/types.ts @@ -570,7 +570,7 @@ export type BuddyAgents = { * @internal */ export type ConfigFlags = { - isEndCallEnabled: boolean; + isEndTaskEnabled: boolean; isEndConsultEnabled: boolean; webRtcEnabled: boolean; autoWrapup: boolean; diff --git a/packages/@webex/contact-center/test/unit/spec/cc.ts b/packages/@webex/contact-center/test/unit/spec/cc.ts index 8a295e18f6d..7b041c2ea03 100644 --- a/packages/@webex/contact-center/test/unit/spec/cc.ts +++ b/packages/@webex/contact-center/test/unit/spec/cc.ts @@ -251,7 +251,7 @@ describe('webex.cc', () => { isAgentAvailableAfterOutdial: false, isCampaignManagementEnabled: false, outDialEp: '', - isEndCallEnabled: false, + isEndTaskEnabled: false, isEndConsultEnabled: false, agentDbId: '', allowConsultToQueue: false, @@ -324,13 +324,13 @@ describe('webex.cc', () => { method: 'connectWebsocket', }); expect(mockTaskManager.setConfigFlags).toHaveBeenCalledWith({ - isEndCallEnabled: mockAgentProfile.isEndCallEnabled, + isEndTaskEnabled: mockAgentProfile.isEndTaskEnabled, isEndConsultEnabled: mockAgentProfile.isEndConsultEnabled, webRtcEnabled: mockAgentProfile.webRtcEnabled, autoWrapup: mockAgentProfile.wrapUpData.wrapUpProps.autoWrapup ?? false, }); expect(mockTaskManager.setConfigFlags).toHaveBeenCalledWith({ - isEndCallEnabled: mockAgentProfile.isEndCallEnabled, + isEndTaskEnabled: mockAgentProfile.isEndTaskEnabled, isEndConsultEnabled: mockAgentProfile.isEndConsultEnabled, webRtcEnabled: mockAgentProfile.webRtcEnabled, autoWrapup: mockAgentProfile.wrapUpData.wrapUpProps.autoWrapup ?? false, diff --git a/packages/@webex/contact-center/test/unit/spec/services/task/Task.ts b/packages/@webex/contact-center/test/unit/spec/services/task/Task.ts index fd459d41bb3..ff93a906a78 100644 --- a/packages/@webex/contact-center/test/unit/spec/services/task/Task.ts +++ b/packages/@webex/contact-center/test/unit/spec/services/task/Task.ts @@ -8,7 +8,7 @@ class DummyTask extends Task { constructor(contact: any, data: TaskData) { super(contact, data, { channelType: 'voice', - isEndCallEnabled: true, + isEndTaskEnabled: true, isEndConsultEnabled: true, }); } diff --git a/packages/@webex/contact-center/test/unit/spec/services/task/TaskFactory.ts b/packages/@webex/contact-center/test/unit/spec/services/task/TaskFactory.ts index c2e054af6b9..da1d92c5c82 100644 --- a/packages/@webex/contact-center/test/unit/spec/services/task/TaskFactory.ts +++ b/packages/@webex/contact-center/test/unit/spec/services/task/TaskFactory.ts @@ -21,7 +21,7 @@ describe('TaskFactory', () => { } as unknown) as WebCallingService; const configFlags: ConfigFlags = { - isEndCallEnabled: true, + isEndTaskEnabled: true, isEndConsultEnabled: true, webRtcEnabled: true, autoWrapup: false, 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 6a1bd0d4102..6019fde155b 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 @@ -338,7 +338,7 @@ describe('TaskManager', () => { contactMock, webCallingService, taskDataMock, - {isEndCallEnabled: true, isEndConsultEnabled: true} + {isEndTaskEnabled: true, isEndConsultEnabled: true} ); (taskManager as any).taskCollection[taskId] = webrtcTask; diff --git a/packages/@webex/contact-center/test/unit/spec/services/task/state-machine/TaskStateMachine.ts b/packages/@webex/contact-center/test/unit/spec/services/task/state-machine/TaskStateMachine.ts index 9734a598c68..b1a7449249e 100644 --- a/packages/@webex/contact-center/test/unit/spec/services/task/state-machine/TaskStateMachine.ts +++ b/packages/@webex/contact-center/test/unit/spec/services/task/state-machine/TaskStateMachine.ts @@ -8,7 +8,7 @@ import {createTaskData} from '../taskTestUtils'; const createConfig = () => ({ channelType: 'voice' as const, - isEndCallEnabled: true, + isEndTaskEnabled: true, isEndConsultEnabled: true, voiceVariant: 'pstn' as const, isRecordingEnabled: true, diff --git a/packages/@webex/contact-center/test/unit/spec/services/task/voice/Voice.ts b/packages/@webex/contact-center/test/unit/spec/services/task/voice/Voice.ts index e8d41d38bbc..ad5f2fd9d3d 100644 --- a/packages/@webex/contact-center/test/unit/spec/services/task/voice/Voice.ts +++ b/packages/@webex/contact-center/test/unit/spec/services/task/voice/Voice.ts @@ -62,7 +62,7 @@ describe('Voice Task', () => { it('hides end and endConsult when disabled', () => { const voice = new Voice(dummyContact, createBaseData(), { - isEndCallEnabled: false, + isEndTaskEnabled: false, isEndConsultEnabled: false, }); voice.updateTaskData(createBaseData()); @@ -99,7 +99,7 @@ describe('Voice Task', () => { it('pauseRecording() calls contact.pauseRecording', async () => { const taskData = createBaseData(); const voice = new Voice(dummyContact, taskData, { - isEndCallEnabled: true, + isEndTaskEnabled: true, isEndConsultEnabled: true, }); primeConnectedState(voice, taskData); @@ -110,7 +110,7 @@ describe('Voice Task', () => { it('resumeRecording() with no payload defaults to autoResumed false', async () => { const taskData = createBaseData(); const voice = new Voice(dummyContact, taskData, { - isEndCallEnabled: true, + isEndTaskEnabled: true, isEndConsultEnabled: true, }); primeConnectedState(voice, taskData); @@ -125,7 +125,7 @@ describe('Voice Task', () => { it('consult() calls contact.consult with payload', async () => { const taskData = createBaseData(); const voice = new Voice(dummyContact, taskData, { - isEndCallEnabled: true, + isEndTaskEnabled: true, isEndConsultEnabled: true, }); primeConnectedState(voice, taskData); @@ -146,7 +146,7 @@ describe('Voice Task', () => { const voice = new Voice( { ...dummyContact, consultTransfer: consultTransferMock }, dataWithState as any, - { isEndCallEnabled: true, isEndConsultEnabled: true } + { isEndTaskEnabled: true, isEndConsultEnabled: true } ); const result = await voice.transfer({ @@ -166,7 +166,7 @@ describe('Voice Task', () => { interaction: {state: 'consulting'} as any, }); const voice = new Voice(dummyContact, dataWithState as any, { - isEndCallEnabled: true, + isEndTaskEnabled: true, isEndConsultEnabled: true, }); @@ -187,7 +187,7 @@ describe('Voice Task', () => { const voice = new Voice( { ...dummyContact, consultTransfer: consultTransferMock }, dataWithDest as any, - { isEndCallEnabled: true, isEndConsultEnabled: true } + { isEndTaskEnabled: true, isEndConsultEnabled: true } ); const result = await voice.transfer({ @@ -211,7 +211,7 @@ describe('Voice Task', () => { const voice = new Voice( { ...dummyContact, consultEnd: consultEndMock }, createBaseData(), - { isEndCallEnabled: true, isEndConsultEnabled: true } + { isEndTaskEnabled: true, isEndConsultEnabled: true } ); const payload = { isConsult: true, queueId: 'q1', taskId: 't1' }; const result = await voice.endConsult(payload); @@ -228,7 +228,7 @@ describe('Voice Task', () => { it('shows main controls and hides accept/decline on AGENT_CONTACT_ASSIGNED', () => { const data: any = { ...createBaseData(), type: CC_EVENTS.AGENT_CONTACT_ASSIGNED }; const voice = new Voice(dummyContact, data, { - isEndCallEnabled: true, + isEndTaskEnabled: true, isEndConsultEnabled: false, }); diff --git a/packages/@webex/contact-center/test/unit/spec/services/task/voice/WebRTC.ts b/packages/@webex/contact-center/test/unit/spec/services/task/voice/WebRTC.ts index 7f7b185fdb2..3c719d8243d 100644 --- a/packages/@webex/contact-center/test/unit/spec/services/task/voice/WebRTC.ts +++ b/packages/@webex/contact-center/test/unit/spec/services/task/voice/WebRTC.ts @@ -73,7 +73,7 @@ describe('WebRTC Task', () => { beforeEach(() => { taskData = createTaskData(); webRtc = new WebRTC(dummyContact, webCallingService, taskData, { - isEndCallEnabled: true, + isEndTaskEnabled: true, isEndConsultEnabled: true, }); }); @@ -128,7 +128,7 @@ describe('WebRTC Task', () => { beforeEach(() => { taskData = createTaskData(); webRtc = new WebRTC(dummyContact, webCallingService, taskData, { - isEndCallEnabled: true, + isEndTaskEnabled: true, isEndConsultEnabled: true, }); }); From e602c3d6e671d8f01d13d2399e745adb29486b11 Mon Sep 17 00:00:00 2001 From: arungane Date: Tue, 9 Dec 2025 18:22:36 +0530 Subject: [PATCH 13/14] feat: update taskmanager with new state machine --- .../contact-center/src/services/task/Task.ts | 132 ++- .../src/services/task/TaskFactory.ts | 11 +- .../src/services/task/TaskManager.ts | 771 ++++++++++++------ .../src/services/task/digital/Digital.ts | 25 +- .../task/state-machine/TaskStateMachine.ts | 167 +++- .../services/task/state-machine/actions.ts | 94 ++- .../services/task/state-machine/constants.ts | 14 +- .../src/services/task/state-machine/types.ts | 51 +- .../src/services/task/voice/Voice.ts | 52 +- .../src/services/task/voice/WebRTC.ts | 6 +- .../unit/spec/services/task/TaskManager.ts | 48 ++ yarn.lock | 1 + 12 files changed, 1053 insertions(+), 319 deletions(-) diff --git a/packages/@webex/contact-center/src/services/task/Task.ts b/packages/@webex/contact-center/src/services/task/Task.ts index 3e843eebf20..62ad0b651c7 100644 --- a/packages/@webex/contact-center/src/services/task/Task.ts +++ b/packages/@webex/contact-center/src/services/task/Task.ts @@ -31,9 +31,19 @@ import { getDefaultUIControls, haveUIControlsChanged, } from './state-machine/uiControlsComputer'; +import type {TaskActionsMap} from './state-machine/actions'; type CallId = string; +export interface TaskActionCallbacks { + onTaskHydrated?: (task: ITask, taskData: TaskData) => void; + onTaskOffered?: (task: ITask, taskData: TaskData) => void; +} + +export interface TaskRuntimeOptions { + actionCallbacks?: TaskActionCallbacks; +} + export default abstract class Task extends EventEmitter implements ITask { protected contact: ReturnType; protected metricsManager: MetricsManager; @@ -44,16 +54,19 @@ export default abstract class Task extends EventEmitter implements ITask { private lastState?: TaskState; protected currentUiControls: TaskUIControls; protected uiControlConfig: UIControlConfig; + protected actionCallbacks?: TaskActionCallbacks; constructor( contact: ReturnType, data: TaskData, - uiControlConfig: UIControlConfig + uiControlConfig: UIControlConfig, + runtimeOptions: TaskRuntimeOptions = {} ) { super(); this.contact = contact; this.data = data; this.uiControlConfig = uiControlConfig; + this.actionCallbacks = runtimeOptions.actionCallbacks; this.metricsManager = MetricsManager.getInstance(); this.webCallMap = {}; this.currentUiControls = getDefaultUIControls(); @@ -167,7 +180,9 @@ export default abstract class Task extends EventEmitter implements ITask { * Initialize the state machine */ private initializeStateMachine(): void { - const machine: TaskStateMachine = createTaskStateMachine(this.uiControlConfig); + const machine: TaskStateMachine = createTaskStateMachine(this.uiControlConfig, { + actions: this.getStateMachineActionOverrides(), + }); this.stateMachineService = createActor(machine); @@ -233,6 +248,119 @@ export default abstract class Task extends EventEmitter implements ITask { } } + private extractTaskDataFromEvent(event?: TaskEventPayload): TaskData | undefined { + if (!event || typeof event !== 'object') { + return undefined; + } + + if ('taskData' in event) { + return (event as {taskData?: TaskData}).taskData; + } + + return undefined; + } + + private updateTaskFromEvent(event?: TaskEventPayload): void { + const taskData = this.extractTaskDataFromEvent(event); + if (taskData) { + this.updateTaskData(taskData); + } + } + + protected getStateMachineActionOverrides(): Partial { + return { + ...this.getCommonActionOverrides(), + ...this.getChannelSpecificActionOverrides(), + }; + } + + protected getChannelSpecificActionOverrides(): Partial { + return {}; + } + + protected createEmitSelfAction( + taskEvent: TASK_EVENTS, + {updateTaskData = false}: {updateTaskData?: boolean} = {} + ) { + return ({event}: {event: TaskEventPayload}) => { + if (updateTaskData) { + this.updateTaskFromEvent(event); + } + this.emit(taskEvent, this); + }; + } + + private getCommonActionOverrides(): Partial { + return { + emitTaskHydrate: ({event}: {event: TaskEventPayload}) => { + const taskData = this.extractTaskDataFromEvent(event); + if (!taskData) { + return; + } + if (this.actionCallbacks?.onTaskHydrated) { + this.actionCallbacks.onTaskHydrated(this, taskData); + } else { + this.updateTaskData(taskData); + this.emit(TASK_EVENTS.TASK_HYDRATE, this); + } + }, + emitTaskOfferContact: ({event}: {event: TaskEventPayload}) => { + const taskData = this.extractTaskDataFromEvent(event); + if (!taskData) { + return; + } + if (this.actionCallbacks?.onTaskOffered) { + this.actionCallbacks.onTaskOffered(this, taskData); + } else { + this.updateTaskData(taskData); + this.emit(TASK_EVENTS.TASK_OFFER_CONTACT, this); + } + }, + emitTaskAssigned: this.createEmitSelfAction(TASK_EVENTS.TASK_ASSIGNED, { + updateTaskData: true, + }), + emitTaskEnd: this.createEmitSelfAction(TASK_EVENTS.TASK_END, {updateTaskData: true}), + emitTaskOfferConsult: this.createEmitSelfAction(TASK_EVENTS.TASK_OFFER_CONSULT, { + updateTaskData: true, + }), + emitTaskConsultCreated: this.createEmitSelfAction(TASK_EVENTS.TASK_CONSULT_CREATED, { + updateTaskData: true, + }), + emitTaskConsulting: ({event}: {event: TaskEventPayload}) => { + this.updateTaskFromEvent(event); + if (this.data.isConsulted) { + this.emit(TASK_EVENTS.TASK_CONSULT_ACCEPTED, this); + } else { + this.emit(TASK_EVENTS.TASK_CONSULTING, this); + } + }, + emitTaskConsultAccepted: this.createEmitSelfAction(TASK_EVENTS.TASK_CONSULT_ACCEPTED), + emitTaskConsultEnd: this.createEmitSelfAction(TASK_EVENTS.TASK_CONSULT_END, { + updateTaskData: true, + }), + emitTaskConsultQueueCancelled: this.createEmitSelfAction( + TASK_EVENTS.TASK_CONSULT_QUEUE_CANCELLED, + { + updateTaskData: true, + } + ), + emitTaskConsultQueueFailed: this.createEmitSelfAction(TASK_EVENTS.TASK_CONSULT_QUEUE_FAILED, { + updateTaskData: true, + }), + emitTaskReject: ({event}: {event: TaskEventPayload}) => { + this.updateTaskFromEvent(event); + const reason = + event && typeof event === 'object' && 'reason' in event + ? (event as {reason?: string}).reason + : undefined; + this.emit(TASK_EVENTS.TASK_REJECT, reason); + }, + emitTaskWrappedup: this.createEmitSelfAction(TASK_EVENTS.TASK_WRAPPEDUP, { + updateTaskData: true, + }), + }; + } + private reconcileData(oldData: TaskData, newData: TaskData): TaskData { Object.keys(newData).forEach((key) => { if (newData[key] && typeof newData[key] === 'object' && !Array.isArray(newData[key])) { diff --git a/packages/@webex/contact-center/src/services/task/TaskFactory.ts b/packages/@webex/contact-center/src/services/task/TaskFactory.ts index 18e9780640a..8b16966e5f3 100644 --- a/packages/@webex/contact-center/src/services/task/TaskFactory.ts +++ b/packages/@webex/contact-center/src/services/task/TaskFactory.ts @@ -1,6 +1,6 @@ import routingContact from './contact'; import WebCallingService from '../WebCallingService'; -import Task from './Task'; +import Task, {TaskRuntimeOptions} from './Task'; import Voice from './voice/Voice'; import WebRTC from './voice/WebRTC'; import Digital from './digital/Digital'; @@ -15,7 +15,8 @@ export default class TaskFactory { contact: ReturnType, webCallingService: WebCallingService, data: TaskData, - configFlags: ConfigFlags + configFlags: ConfigFlags, + runtimeOptions: TaskRuntimeOptions = {} ): Task { const mediaType = data.interaction.mediaType ?? MEDIA_CHANNEL.TELEPHONY; const {isEndTaskEnabled, isEndConsultEnabled} = configFlags; @@ -29,15 +30,15 @@ export default class TaskFactory { switch (mediaType) { case MEDIA_CHANNEL.TELEPHONY: if (webCallingService.loginOption === 'BROWSER') { - return new WebRTC(contact, webCallingService, data, voiceControlOptions); + return new WebRTC(contact, webCallingService, data, voiceControlOptions, runtimeOptions); } - return new Voice(contact, data, voiceControlOptions); + return new Voice(contact, data, voiceControlOptions, runtimeOptions); case MEDIA_CHANNEL.CHAT: case MEDIA_CHANNEL.EMAIL: case MEDIA_CHANNEL.SOCIAL: - return new Digital(contact, data); + return new Digital(contact, data, runtimeOptions); default: throw new Error(`Unknown media type: ${mediaType}`); diff --git a/packages/@webex/contact-center/src/services/task/TaskManager.ts b/packages/@webex/contact-center/src/services/task/TaskManager.ts index d68fd5dcb46..8e5134e56a4 100644 --- a/packages/@webex/contact-center/src/services/task/TaskManager.ts +++ b/packages/@webex/contact-center/src/services/task/TaskManager.ts @@ -15,6 +15,7 @@ import TaskFactory from './TaskFactory'; import WebRTC from './voice/WebRTC'; import {TaskEvent, type TaskEventPayload} from './state-machine'; import {normalizeTaskData} from './taskDataNormalizer'; +import type {TaskActionCallbacks, TaskRuntimeOptions} from './Task'; type WebSocketPayload = TaskData & { type: CC_EVENTS | string; @@ -27,6 +28,40 @@ type WebSocketMessage = { data: WebSocketPayload; }; +/** + * Actions to be performed after handling an event + * + * These actions represent TaskManager-level concerns (task collection lifecycle, + * resource cleanup) rather than task-level state machine concerns. The separation + * ensures proper responsibility: + * - TaskManager: Collection management, metrics, cleanup + * - State Machine: Task state transitions, event emissions, UI controls + */ +interface TaskEventActions { + task?: ITask; + shouldCleanupTask?: boolean; + shouldRemoveFromCollection?: boolean; + shouldCancelAutoWrapup?: boolean; + shouldEmitTaskIncoming?: boolean; +} + +/** + * Context for processing an event + * + * Contains all information needed to process a WebSocket event: + * - Event type and payload from the backend + * - Task instance (if exists) + * - Pre-mapped state machine event (if applicable) + * - Task state flags (e.g., was this a consulted task) + */ +interface EventContext { + eventType: CC_EVENTS; + payload: WebSocketPayload; + task?: ITask; + stateMachineEvent?: TaskEventPayload | null; + wasConsultedTask: boolean; +} + 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); @@ -47,6 +82,7 @@ export default class TaskManager extends EventEmitter { private wrapupData: WrapupData; private agentId: string; private configFlags?: ConfigFlags; + private taskActionCallbacks: TaskActionCallbacks; /** * @param contact - Routing Contact layer. Talks to AQMReq layer to convert events to promises * @param webCallingService - Webrtc Service Layer @@ -63,6 +99,7 @@ export default class TaskManager extends EventEmitter { this.webSocketManager = webSocketManager; this.taskCollection = {}; this.metricsManager = MetricsManager.getInstance(); + this.taskActionCallbacks = this.createTaskActionCallbacks(); this.registerTaskListeners(); this.registerIncomingCallEvent(); } @@ -132,61 +169,105 @@ export default class TaskManager extends EventEmitter { switch (ccEvent) { case CC_EVENTS.AGENT_CONTACT_RESERVED: + return {type: TaskEvent.TASK_INCOMING, taskData: payload}; + case CC_EVENTS.AGENT_OFFER_CONTACT: - return {type: TaskEvent.OFFER, taskData: payload}; + return {type: TaskEvent.TASK_OFFERED, taskData: payload}; + + case CC_EVENTS.AGENT_CONTACT: + return {type: TaskEvent.HYDRATE, taskData: payload}; case CC_EVENTS.AGENT_OFFER_CONSULT: - return {type: TaskEvent.OFFER_CONSULT, taskData: payload}; + return { + type: TaskEvent.OFFER_CONSULT, + taskData: {...payload, isConsulted: true}, + }; case CC_EVENTS.AGENT_CONTACT_ASSIGNED: return {type: TaskEvent.ASSIGN, taskData: payload}; case CC_EVENTS.AGENT_CONTACT_HELD: - return {type: TaskEvent.HOLD, mediaResourceId: mediaResourceId || ''}; + return { + type: TaskEvent.HOLD_SUCCESS, + mediaResourceId: mediaResourceId || '', + taskData: payload, + }; case CC_EVENTS.AGENT_CONTACT_UNHELD: - return {type: TaskEvent.UNHOLD, mediaResourceId: mediaResourceId || ''}; + return { + type: TaskEvent.UNHOLD_SUCCESS, + mediaResourceId: mediaResourceId || '', + taskData: payload, + }; case CC_EVENTS.AGENT_CONSULT_CREATED: - return {type: TaskEvent.CONSULT_CREATED, taskData: payload}; + return { + type: TaskEvent.CONSULT_CREATED, + taskData: {...payload, isConsulted: false}, + }; case CC_EVENTS.AGENT_CONSULTING: return { type: TaskEvent.CONSULTING_ACTIVE, consultDestinationAgentJoined: true, + taskData: payload, }; case CC_EVENTS.AGENT_CONSULT_ENDED: - return {type: TaskEvent.CONSULT_END}; + return {type: TaskEvent.CONSULT_END, taskData: payload}; case CC_EVENTS.AGENT_CONSULT_FAILED: - return {type: TaskEvent.CONSULT_FAILED, reason: payload.reason}; + return {type: TaskEvent.CONSULT_FAILED, reason: payload.reason, taskData: payload}; case CC_EVENTS.AGENT_CTQ_CANCELLED: - return {type: TaskEvent.CTQ_CANCEL}; + return {type: TaskEvent.CTQ_CANCEL, taskData: payload}; + + case CC_EVENTS.AGENT_CTQ_CANCEL_FAILED: + return {type: TaskEvent.CTQ_CANCEL_FAILED, taskData: payload}; case CC_EVENTS.AGENT_VTEAM_TRANSFERRED: case CC_EVENTS.AGENT_WRAPUP: case CC_EVENTS.AGENT_CONTACT_UNASSIGNED: - return {type: TaskEvent.WRAPUP_START}; + return {type: TaskEvent.END, taskData: {...payload, wrapUpRequired: true}}; + + case CC_EVENTS.AGENT_BLIND_TRANSFER_FAILED: + case CC_EVENTS.AGENT_VTEAM_TRANSFER_FAILED: + case CC_EVENTS.AGENT_CONSULT_TRANSFER_FAILED: + case CC_EVENTS.AGENT_CONFERENCE_TRANSFER_FAILED: + return {type: TaskEvent.TRANSFER_FAILED, taskData: payload}; case CC_EVENTS.CONTACT_ENDED: - return {type: TaskEvent.CONTACT_ENDED}; + return { + type: TaskEvent.CONTACT_ENDED, + taskData: { + ...payload, + wrapUpRequired: payload.interaction?.state !== 'new', + }, + }; case CC_EVENTS.AGENT_INVITE_FAILED: return {type: TaskEvent.INVITE_FAILED, reason: payload.reason}; + case CC_EVENTS.AGENT_CONTACT_ASSIGN_FAILED: + return {type: TaskEvent.ASSIGN_FAILED, reason: payload.reason}; + case CC_EVENTS.AGENT_CONTACT_OFFER_RONA: - return {type: TaskEvent.RONA}; + return {type: TaskEvent.RONA, taskData: payload, reason: payload.reason}; + + case CC_EVENTS.AGENT_OUTBOUND_FAILED: + return {type: TaskEvent.OUTBOUND_FAILED, reason: payload.reason}; case CC_EVENTS.CONTACT_RECORDING_STARTED: return {type: TaskEvent.RECORDING_STARTED, taskData: payload}; case CC_EVENTS.CONTACT_RECORDING_PAUSED: - return {type: TaskEvent.PAUSE_RECORDING}; + return {type: TaskEvent.PAUSE_RECORDING, taskData: payload}; case CC_EVENTS.CONTACT_RECORDING_RESUMED: - return {type: TaskEvent.RESUME_RECORDING}; + return {type: TaskEvent.RESUME_RECORDING, taskData: payload}; + + case CC_EVENTS.AGENT_WRAPPEDUP: + return {type: TaskEvent.WRAPUP_COMPLETE, taskData: payload}; default: // Not all events need state machine mapping @@ -200,273 +281,433 @@ export default class TaskManager extends EventEmitter { * @param payload - The event payload * @param task - The task instance */ - private static sendEventToStateMachine( + private sendEventToStateMachine( ccEvent: CC_EVENTS, payload: WebSocketPayload, - task?: ITask + task?: ITask, + stateMachineEvent?: TaskEventPayload | null ): void { - // Check if task has state machine (will be added in Task interface) + // Check if task has state machine const taskWithStateMachine = task as any; - if (!taskWithStateMachine?.stateMachineService) { + if (!taskWithStateMachine?.sendStateMachineEvent) { return; } - const stateMachineEvent = TaskManager.mapEventToTaskStateMachineEvent(ccEvent, payload); + const eventPayload = + stateMachineEvent ?? TaskManager.mapEventToTaskStateMachineEvent(ccEvent, payload); - if (stateMachineEvent) { - LoggerProxy.log(`Sending event to state machine: ${ccEvent} -> ${stateMachineEvent.type}`, { + if (eventPayload) { + LoggerProxy.log(`Sending event to state machine: ${ccEvent} -> ${eventPayload.type}`, { module: TASK_MANAGER_FILE, method: 'sendEventToStateMachine', interactionId: payload.interactionId, }); - // Send event to task's state machine - taskWithStateMachine.stateMachineService.send(stateMachineEvent); + // Send event to task's state machine using the protected method + taskWithStateMachine.sendStateMachineEvent(eventPayload); } } + /** + * Register WebSocket message listeners for task events + * + * Main entry point that orchestrates event processing through a clear pipeline: + * 1. Parse and validate incoming WebSocket messages + * 2. Prepare event context with task and state machine mappings + * 3. Handle task lifecycle (creation, updates, collection management) + * 4. Send events to state machine (which handles task-level emissions) + * 5. Execute cleanup actions (resource management, collection updates) + * + * This architecture separates concerns: + * - TaskManager: Manages task collection lifecycle and operational concerns + * - State Machine: Manages individual task state and event emissions + */ private registerTaskListeners() { this.webSocketManager.on('message', (event) => { + // Step 1: Parse and validate the message + const message = this.parseWebSocketMessage(event); + if (!message) return; + + // Step 2: Prepare event context + const context = this.prepareEventContext(message); + if (!context) return; + + // Step 3: Handle event lifecycle and get actions to perform + const actions = this.handleTaskLifecycleEvent(context); + + // Step 4: Process state machine events and emit legacy events + this.processEventAndEmissions(context, actions); + + // Step 5: Execute post-processing actions + this.executeTaskActions(actions); + }); + } + + /** + * Parse and validate WebSocket message + * @returns Parsed message or null if invalid/keepalive + */ + private parseWebSocketMessage(event: string): WebSocketMessage | null { + try { const payload = JSON.parse(event) as WebSocketMessage; + + // Filter out keepalive messages if (payload?.keepalive === 'true' || payload?.keepalive === true) { - return; + return null; } + + // Normalize task data if present if (payload?.data?.interaction) { payload.data = normalizeTaskData(payload.data); } - // Re-emit the task events to the task object - let task: ITask; - const eventType = payload.data?.type; - if (eventType && isCcEvent(eventType)) { - task = this.taskCollection[payload.data.interactionId]; + return payload; + } catch (error) { + LoggerProxy.error('Failed to parse WebSocket message', { + module: TASK_MANAGER_FILE, + method: 'parseWebSocketMessage', + error, + }); - LoggerProxy.info(`Handling task event ${eventType}`, { - module: TASK_MANAGER_FILE, - method: METHODS.REGISTER_TASK_LISTENERS, - interactionId: payload.data?.interactionId, - }); - switch (eventType) { - case CC_EVENTS.AGENT_CONTACT: - if (!task) { - // Re-create task if it does not exist - // This can happen when the task is created after the event is received (multi session) - task = TaskFactory.createTask( - this.contact, - this.webCallingService, - {...payload.data, isConsulted: false}, - this.configFlags - ); - this.taskCollection[payload.data.interactionId] = task; - } - this.updateTaskData(task, payload.data); - this.emit(TASK_EVENTS.TASK_HYDRATE, task); - break; - - case CC_EVENTS.AGENT_CONTACT_RESERVED: - task = TaskFactory.createTask( - this.contact, - this.webCallingService, - {...payload.data, isConsulted: false}, - this.configFlags - ); - this.taskCollection[payload.data.interactionId] = task; - // for telephony in-browser we wait for incoming call, else fire immediately - if ( - this.webCallingService.loginOption !== LoginOption.BROWSER || - task.data.interaction.mediaType !== MEDIA_CHANNEL.TELEPHONY || - this.call - ) { - this.emit(TASK_EVENTS.TASK_INCOMING, task); - } - break; - case CC_EVENTS.AGENT_OFFER_CONTACT: - this.updateTaskData(task, payload.data); - LoggerProxy.log(`Agent offer contact received for task`, { - module: TASK_MANAGER_FILE, - method: METHODS.REGISTER_TASK_LISTENERS, - interactionId: payload.data?.interactionId, - }); - this.emit(TASK_EVENTS.TASK_OFFER_CONTACT, task); - break; - case CC_EVENTS.AGENT_OUTBOUND_FAILED: - // We don't have to emit any event here since this will be result of promise. - if (task.data) { - this.removeTaskFromCollection(task); - } - LoggerProxy.log(`Agent outbound failed for task`, { - module: TASK_MANAGER_FILE, - method: METHODS.REGISTER_TASK_LISTENERS, - interactionId: payload.data?.interactionId, - }); - break; - case CC_EVENTS.AGENT_CONTACT_ASSIGNED: - this.updateTaskData(task, payload.data); - task.emit(TASK_EVENTS.TASK_ASSIGNED, task); - break; - case CC_EVENTS.AGENT_CONTACT_UNASSIGNED: - this.updateTaskData(task, { - ...payload.data, - wrapUpRequired: true, - }); - task.emit(TASK_EVENTS.TASK_END, task); - break; - case CC_EVENTS.AGENT_CONTACT_OFFER_RONA: - case CC_EVENTS.AGENT_CONTACT_ASSIGN_FAILED: - case CC_EVENTS.AGENT_INVITE_FAILED: { - this.updateTaskData(task, payload.data); - - const eventTypeToMetricMap: Record = { - [CC_EVENTS.AGENT_CONTACT_ASSIGN_FAILED]: 'AGENT_CONTACT_ASSIGN_FAILED', - [CC_EVENTS.AGENT_INVITE_FAILED]: 'AGENT_INVITE_FAILED', - }; - const metricEventName: keyof typeof METRIC_EVENT_NAMES = - eventTypeToMetricMap[payload.data.type] || 'AGENT_RONA'; - - this.metricsManager.trackEvent( - METRIC_EVENT_NAMES[metricEventName], - { - ...MetricsManager.getCommonTrackingFieldForAQMResponse(payload.data), - taskId: payload.data.interactionId, - reason: payload.data.reason, - }, - ['behavioral', 'operational'] - ); - this.handleTaskCleanup(task); - task.emit(TASK_EVENTS.TASK_REJECT, payload.data.reason); - break; - } - case CC_EVENTS.CONTACT_ENDED: - this.updateTaskData(task, { - ...payload.data, - wrapUpRequired: payload.data.interaction.state !== 'new', - }); - this.handleTaskCleanup(task); - task.emit(TASK_EVENTS.TASK_END, task); - - break; - case CC_EVENTS.AGENT_CONTACT_HELD: - // As soon as the main interaction is held, we need to emit TASK_HOLD - this.updateTaskData(task, payload.data); - task.emit(TASK_EVENTS.TASK_HOLD, task); - break; - case CC_EVENTS.AGENT_CONTACT_UNHELD: - // As soon as the main interaction is unheld, we need to emit TASK_RESUME - this.updateTaskData(task, payload.data); - task.emit(TASK_EVENTS.TASK_RESUME, task); - break; - case CC_EVENTS.AGENT_VTEAM_TRANSFERRED: - this.updateTaskData(task, { - ...payload.data, - wrapUpRequired: true, - }); - task.emit(TASK_EVENTS.TASK_END, task); - break; - case CC_EVENTS.AGENT_CTQ_CANCEL_FAILED: - this.updateTaskData(task, payload.data); - task.emit(TASK_EVENTS.TASK_CONSULT_QUEUE_FAILED, task); - break; - case CC_EVENTS.AGENT_CONSULT_CREATED: - // Received when self agent initiates a consult - this.updateTaskData(task, { - ...payload.data, - isConsulted: false, // This ensures that the task consult status is always reset - }); - task.emit(TASK_EVENTS.TASK_CONSULT_CREATED, task); - break; - case CC_EVENTS.AGENT_OFFER_CONSULT: - // Received when other agent sends us a consult offer - this.updateTaskData(task, { - ...payload.data, - isConsulted: true, // This ensures that the task is marked as us being requested for a consult - }); - task.emit(TASK_EVENTS.TASK_OFFER_CONSULT, task); - break; - case CC_EVENTS.AGENT_CONSULTING: - // Received when agent is in an active consult state - // TODO: Check if we can use backend consult state instead of isConsulted - this.updateTaskData(task, payload.data); - if (task.data.isConsulted) { - // Fire only if you are the agent who received the consult request - task.emit(TASK_EVENTS.TASK_CONSULT_ACCEPTED, task); - } else { - // Fire only if you are the agent who initiated the consult - task.emit(TASK_EVENTS.TASK_CONSULTING, task); - } - break; - case CC_EVENTS.AGENT_CONSULT_FAILED: - // This can only be received by the agent who initiated the consult. - // We need not emit any event here since this will be result of promise - this.updateTaskData(task, payload.data); - break; - case CC_EVENTS.AGENT_CONSULT_ENDED: - this.updateTaskData(task, payload.data); - if (task.data.isConsulted) { - // This will be the end state of the task as soon as we end the consult in case of - // us being offered a consult - this.removeTaskFromCollection(task); - } - task.emit(TASK_EVENTS.TASK_CONSULT_END, task); - break; - case CC_EVENTS.AGENT_CTQ_CANCELLED: - // This event is received when the consult using queue is cancelled using API - this.updateTaskData(task, payload.data); - task.emit(TASK_EVENTS.TASK_CONSULT_QUEUE_CANCELLED, task); - break; - case CC_EVENTS.AGENT_WRAPUP: - this.updateTaskData(task, {...payload.data, wrapUpRequired: true}); - task.emit(TASK_EVENTS.TASK_END, task); - break; - case CC_EVENTS.AGENT_WRAPPEDUP: - task.cancelAutoWrapupTimer(); - this.removeTaskFromCollection(task); - task.emit(TASK_EVENTS.TASK_WRAPPEDUP, task); - break; - case CC_EVENTS.CONTACT_RECORDING_STARTED: - this.updateTaskData(task, payload.data); - task.emit(TASK_EVENTS.TASK_RECORDING_STARTED, task); - break; - case CC_EVENTS.CONTACT_RECORDING_PAUSED: - this.updateTaskData(task, payload.data); - task.emit(TASK_EVENTS.TASK_RECORDING_PAUSED, task); - break; - case CC_EVENTS.CONTACT_RECORDING_PAUSE_FAILED: - this.updateTaskData(task, payload.data); - task.emit(TASK_EVENTS.TASK_RECORDING_PAUSE_FAILED, task); - break; - case CC_EVENTS.CONTACT_RECORDING_RESUMED: - this.updateTaskData(task, payload.data); - task.emit(TASK_EVENTS.TASK_RECORDING_RESUMED, task); - break; - case CC_EVENTS.CONTACT_RECORDING_RESUME_FAILED: - this.updateTaskData(task, payload.data); - task.emit(TASK_EVENTS.TASK_RECORDING_RESUME_FAILED, task); - break; - case CC_EVENTS.CONSULTED_PARTICIPANT_MOVING: - // Participant is being moved/transferred - update task state with movement info - this.updateTaskData(task, payload.data); - break; - case CC_EVENTS.PARTICIPANT_POST_CALL_ACTIVITY: - // Post-call activity for participant - update task state with activity details - this.updateTaskData(task, payload.data); - break; - default: - break; - } + return null; + } + } - // Send all events to state machine after processing - // Task may have been created in AGENT_CONTACT or AGENT_CONTACT_RESERVED cases - if (task) { - // Only emit task-specific events to the task object - if (Object.values(CC_TASK_EVENTS).includes(eventType as any)) { - task.emit(eventType as any, payload.data); - } + /** + * Prepare context for event processing + * @returns Event context or null if event type is invalid + */ + private prepareEventContext(message: WebSocketMessage): EventContext | null { + const eventType = message.data?.type; - // Send event to state machine for all events - TaskManager.sendEventToStateMachine(eventType, payload.data, task); - } - } + if (!eventType || !isCcEvent(eventType)) { + return null; + } + + const task = this.taskCollection[message.data.interactionId]; + const stateMachineEvent = TaskManager.mapEventToTaskStateMachineEvent(eventType, message.data); + + LoggerProxy.info(`Handling task event ${eventType}`, { + module: TASK_MANAGER_FILE, + method: 'prepareEventContext', + interactionId: message.data?.interactionId, }); + + return { + eventType, + payload: message.data, + task, + stateMachineEvent, + wasConsultedTask: Boolean(task?.data?.isConsulted), + }; + } + + /** + * Handle task lifecycle events and determine required actions + * + * Delegates to specific event handlers based on event type. Each handler + * is responsible for TaskManager-level concerns: + * - Task creation and collection management + * - Metrics tracking + * - Resource cleanup decisions + * + * Note: Task-level state transitions and event emissions are handled by + * the state machine via processEventAndEmissions() + */ + private handleTaskLifecycleEvent(context: EventContext): TaskEventActions { + const {eventType} = context; + + switch (eventType) { + case CC_EVENTS.AGENT_CONTACT_RESERVED: + return this.handleContactReserved(context); + + case CC_EVENTS.AGENT_CONTACT: + return this.handleAgentContact(context); + + case CC_EVENTS.AGENT_OUTBOUND_FAILED: + return this.handleOutboundFailed(context); + + case CC_EVENTS.AGENT_CONTACT_OFFER_RONA: + case CC_EVENTS.AGENT_CONTACT_ASSIGN_FAILED: + case CC_EVENTS.AGENT_INVITE_FAILED: + return this.handleTaskFailure(context); + + case CC_EVENTS.CONTACT_ENDED: + return this.handleContactEnded(context); + + case CC_EVENTS.AGENT_CONSULT_ENDED: + return this.handleConsultEnded(context); + + case CC_EVENTS.AGENT_WRAPPEDUP: + return this.handleWrapupComplete(context); + + case CC_EVENTS.CONSULTED_PARTICIPANT_MOVING: + case CC_EVENTS.PARTICIPANT_POST_CALL_ACTIVITY: + return this.handleTaskDataUpdate(context); + + default: + return this.handleDefaultEvent(context); + } + } + + /** + * Handle AGENT_CONTACT_RESERVED event + * Creates a new task and sends TASK_INCOMING event to state machine + */ + private handleContactReserved(context: EventContext): TaskEventActions { + const {payload} = context; + + const task = TaskFactory.createTask( + this.contact, + this.webCallingService, + {...payload, isConsulted: false}, + this.configFlags, + this.getTaskRuntimeOptions() + ); + + this.taskCollection[payload.interactionId] = task; + + // For telephony in-browser, we need to wait for the incoming call event + // before the state machine can properly emit TASK_INCOMING + // The state machine will handle emitting TASK_INCOMING via the callback + const shouldWaitForIncomingCall = + this.webCallingService.loginOption === LoginOption.BROWSER && + task.data.interaction.mediaType === MEDIA_CHANNEL.TELEPHONY && + !this.call; + + if (shouldWaitForIncomingCall) { + // Don't send to state machine yet - wait for handleIncomingWebCall + return {task}; + } + + // For all other cases, let the state machine handle TASK_INCOMING emission + return {task}; + } + + /** + * Handle AGENT_CONTACT event + * Re-creates task if missing (multi-session scenario) + */ + private handleAgentContact(context: EventContext): TaskEventActions { + let {task} = context; + const {payload} = context; + + if (!task) { + task = TaskFactory.createTask( + this.contact, + this.webCallingService, + {...payload, isConsulted: false}, + this.configFlags, + this.getTaskRuntimeOptions() + ); + this.taskCollection[payload.interactionId] = task; + } + + return {task}; + } + + /** + * Handle AGENT_OUTBOUND_FAILED event + * + * TaskManager responsibility: Mark failed outbound tasks for removal from collection. + * The state machine handles the task-level OUTBOUND_FAILED event emission. + */ + private handleOutboundFailed(context: EventContext): TaskEventActions { + const {task, payload} = context; + + if (task?.data) { + LoggerProxy.log('Agent outbound failed for task', { + module: TASK_MANAGER_FILE, + method: 'handleOutboundFailed', + interactionId: payload?.interactionId, + }); + + return {task, shouldRemoveFromCollection: true}; + } + + return {task}; + } + + /** + * Handle task failure events (RONA, ASSIGN_FAILED, INVITE_FAILED) + * + * TaskManager responsibilities: + * - Track operational metrics for failed tasks + * - Mark tasks for cleanup + * + * The state machine handles task-level state transitions and event emissions + * (RONA, ASSIGN_FAILED, INVITE_FAILED events). + */ + private handleTaskFailure(context: EventContext): TaskEventActions { + const {task, eventType, payload} = context; + + if (!task) { + return {}; + } + + // Map event type to metric name + const eventTypeToMetricMap: Record = { + [CC_EVENTS.AGENT_CONTACT_ASSIGN_FAILED]: 'AGENT_CONTACT_ASSIGN_FAILED', + [CC_EVENTS.AGENT_INVITE_FAILED]: 'AGENT_INVITE_FAILED', + }; + + const metricEventName: keyof typeof METRIC_EVENT_NAMES = + eventTypeToMetricMap[eventType] || 'AGENT_RONA'; + + // Track operational metrics (TaskManager-level concern) + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES[metricEventName], + { + ...MetricsManager.getCommonTrackingFieldForAQMResponse(payload), + taskId: payload.interactionId, + reason: payload.reason, + }, + ['behavioral', 'operational'] + ); + + return {task, shouldCleanupTask: true}; + } + + /** + * Handle CONTACT_ENDED event + * + * TaskManager responsibility: Mark tasks for cleanup (WebRTC resources, timers). + * The state machine handles CONTACT_ENDED event emission and state transitions. + */ + private handleContactEnded(context: EventContext): TaskEventActions { + const {task} = context; + + if (task) { + return {task, shouldCleanupTask: true}; + } + + return {}; + } + + /** + * Handle AGENT_CONSULT_ENDED event + * + * TaskManager responsibility: Remove consulted tasks from collection when consult ends. + * The state machine handles CONSULT_END event emission and state transitions. + */ + private handleConsultEnded(context: EventContext): TaskEventActions { + const {task, wasConsultedTask} = context; + + if (task && wasConsultedTask) { + // End state for task if we were offered the consult + return {task, shouldRemoveFromCollection: true}; + } + + return {task}; + } + + /** + * Handle AGENT_WRAPPEDUP event + * + * TaskManager responsibilities: + * - Cancel auto-wrapup timer (resource cleanup) + * - Remove completed tasks from collection + * + * The state machine handles WRAPUP_COMPLETE event emission and state transitions. + */ + private handleWrapupComplete(context: EventContext): TaskEventActions { + const {task} = context; + + if (task) { + return { + task, + shouldCancelAutoWrapup: true, + shouldRemoveFromCollection: true, + }; + } + + return {}; + } + + /** + * Handle events that only need task data updates + */ + private handleTaskDataUpdate(context: EventContext): TaskEventActions { + const {task, payload} = context; + + if (task) { + this.updateTaskData(task, payload); + } + + return {task}; + } + + /** + * Handle default/other events + */ + private handleDefaultEvent(context: EventContext): TaskEventActions { + const {task, payload, stateMachineEvent} = context; + + // For all other events, just update task data if needed + if (task && payload && !stateMachineEvent) { + this.updateTaskData(task, payload); + } + + return {task}; + } + + /** + * Process state machine events and emit legacy events + * + * This method bridges TaskManager and the state machine: + * 1. Emits legacy CC_TASK_EVENTS for backward compatibility + * 2. Sends events to state machine for: + * - Task state transitions + * - TASK_EVENTS emissions (via callbacks) + * - UI controls updates + * + * Note: TASK_EVENTS (like TASK_INCOMING, TASK_END, etc.) are now emitted + * by the state machine via callbacks, not directly by TaskManager. This ensures + * events are emitted in sync with state transitions. + */ + private processEventAndEmissions(context: EventContext, actions: TaskEventActions): void { + const {task} = actions; + if (!task) return; + + const {eventType, payload, stateMachineEvent} = context; + + // Emit task-specific events for backward compatibility + if (Object.values(CC_TASK_EVENTS).includes(eventType as any)) { + task.emit(eventType as any, payload); + } + + // Send event to state machine - this will trigger all TASK_EVENTS emissions + // including TASK_INCOMING which is now handled via the state machine callbacks + this.sendEventToStateMachine(eventType, payload, task, stateMachineEvent); + } + + /** + * Execute post-processing actions on tasks + * + * Handles TaskManager-level lifecycle concerns: + * - Cancel timers (auto-wrapup) + * - Cleanup resources (WebRTC, call objects) + * - Manage task collection (remove completed/failed tasks) + * + * These are manager-level operations, distinct from task-level state + * changes handled by the state machine. + */ + private executeTaskActions(actions: TaskEventActions): void { + const {task, shouldCancelAutoWrapup, shouldCleanupTask, shouldRemoveFromCollection} = actions; + + if (!task) return; + + if (shouldCancelAutoWrapup) { + task.cancelAutoWrapupTimer(); + } + + if (shouldCleanupTask) { + this.handleTaskCleanup(task); + } + + if (shouldRemoveFromCollection) { + this.removeTaskFromCollection(task); + } } private updateTaskData(task: ITask, taskData: TaskData): ITask { @@ -497,6 +738,34 @@ export default class TaskManager extends EventEmitter { } } + private getTaskRuntimeOptions(): TaskRuntimeOptions { + return { + actionCallbacks: this.taskActionCallbacks, + }; + } + + private createTaskActionCallbacks(): TaskActionCallbacks { + return { + onTaskHydrated: (task, taskData) => { + if (taskData) { + this.updateTaskData(task, taskData); + } + this.emit(TASK_EVENTS.TASK_HYDRATE, task); + }, + onTaskOffered: (task, taskData) => { + LoggerProxy.log(`Agent offer contact received for task`, { + module: TASK_MANAGER_FILE, + method: METHODS.REGISTER_TASK_LISTENERS, + interactionId: taskData?.interactionId, + }); + if (taskData) { + this.updateTaskData(task, taskData); + } + this.emit(TASK_EVENTS.TASK_OFFER_CONTACT, task); + }, + }; + } + private removeTaskFromCollection(task: ITask) { if (task?.data?.interactionId) { delete this.taskCollection[task.data.interactionId]; diff --git a/packages/@webex/contact-center/src/services/task/digital/Digital.ts b/packages/@webex/contact-center/src/services/task/digital/Digital.ts index b65d7aa3ec8..e44e92275cb 100644 --- a/packages/@webex/contact-center/src/services/task/digital/Digital.ts +++ b/packages/@webex/contact-center/src/services/task/digital/Digital.ts @@ -2,19 +2,28 @@ import {CC_FILE, METHODS} from '../../../constants'; import {getErrorDetails} from '../../core/Utils'; import routingContact from '../contact'; import {IDigital, TaskResponse, TaskData, TASK_CHANNEL_TYPE} from '../types'; -import Task from '../Task'; +import Task, {TaskRuntimeOptions} from '../Task'; import LoggerProxy from '../../../logger-proxy'; import MetricsManager from '../../../metrics/MetricsManager'; import {METRIC_EVENT_NAMES} from '../../../metrics/constants'; export default class Digital extends Task implements IDigital { - constructor(contact: ReturnType, data: TaskData) { - super(contact, data, { - channelType: TASK_CHANNEL_TYPE.DIGITAL, - isEndTaskEnabled: true, - isEndConsultEnabled: false, - isRecordingEnabled: false, - }); + constructor( + contact: ReturnType, + data: TaskData, + runtimeOptions: TaskRuntimeOptions = {} + ) { + super( + contact, + data, + { + channelType: TASK_CHANNEL_TYPE.DIGITAL, + isEndTaskEnabled: true, + isEndConsultEnabled: false, + isRecordingEnabled: false, + }, + runtimeOptions + ); } /** diff --git a/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts b/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts index 5b1fb16dbd4..49c79fa8b89 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts @@ -8,7 +8,7 @@ import {setup} from 'xstate'; import {TaskContext, TaskEventPayload, UIControlConfig} from './types'; import {TaskState, TaskEvent} from './constants'; -import {actions, createInitialContext} from './actions'; +import {actions, createInitialContext, TaskActionsMap} from './actions'; type TaskActionConfigMap = {[K in keyof typeof actions]: undefined}; @@ -40,16 +40,33 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { context: createInitialContext(uiControlConfig, TaskState.IDLE), on: { [TaskEvent.RECORDING_STARTED]: { - actions: ['updateTaskData'], + actions: ['updateTaskData', 'emitTaskRecordingStarted'], + }, + [TaskEvent.HYDRATE]: { + actions: ['updateTaskData', 'emitTaskHydrate'], + }, + [TaskEvent.CTQ_CANCEL]: { + actions: ['updateTaskData', 'emitTaskConsultQueueCancelled'], + }, + [TaskEvent.CTQ_CANCEL_FAILED]: { + actions: ['updateTaskData', 'emitTaskConsultQueueFailed'], }, }, states: { [TaskState.IDLE]: { on: { + [TaskEvent.TASK_INCOMING]: { + target: TaskState.OFFERED, + actions: ['initializeTask'], + }, [TaskEvent.OFFER]: { target: TaskState.OFFERED, actions: ['initializeTask'], }, + [TaskEvent.OFFER_CONTACT]: { + target: TaskState.OFFERED, + actions: ['initializeTask', 'emitTaskOfferContact'], + }, [TaskEvent.OFFER_CONSULT]: { target: TaskState.OFFERED_CONSULT, actions: ['initializeTask'], @@ -59,42 +76,85 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { [TaskState.OFFERED]: { on: { + [TaskEvent.TASK_OFFERED]: { + actions: ['updateTaskData', 'emitTaskOfferContact'], + }, + [TaskEvent.ACCEPT_INITIATED]: { + actions: ['setAcceptInitiated'], + }, [TaskEvent.ACCEPT]: { target: TaskState.CONNECTED, }, [TaskEvent.ASSIGN]: { target: TaskState.CONNECTED, - actions: ['updateTaskData'], + actions: ['updateTaskData', 'emitTaskAssigned'], + }, + [TaskEvent.DECLINE]: { + target: TaskState.TERMINATED, + actions: ['updateTaskData', 'markEnded', 'emitTaskReject'], }, [TaskEvent.RONA]: { target: TaskState.TERMINATED, - actions: ['markEnded'], + actions: ['updateTaskData', 'markEnded', 'emitTaskReject'], }, [TaskEvent.END]: { target: TaskState.TERMINATED, - actions: ['markEnded'], + actions: ['updateTaskData', 'markEnded', 'emitTaskEnd'], + }, + [TaskEvent.ASSIGN_FAILED]: { + target: TaskState.TERMINATED, + actions: ['updateTaskData', 'markEnded', 'emitTaskReject'], + }, + [TaskEvent.INVITE_FAILED]: { + target: TaskState.TERMINATED, + actions: ['updateTaskData', 'markEnded', 'emitTaskReject'], + }, + [TaskEvent.OUTBOUND_FAILED]: { + target: TaskState.TERMINATED, + actions: ['updateTaskData', 'markEnded', 'emitTaskReject'], + }, + [TaskEvent.CONSULT_ACCEPTED]: { + target: TaskState.CONSULTING, + actions: ['updateTaskData', 'handleConsultAccept', 'emitTaskConsultAccepted'], }, }, }, [TaskState.OFFERED_CONSULT]: { + entry: ['emitTaskOfferConsult'], on: { + [TaskEvent.ACCEPT_INITIATED]: { + actions: ['setAcceptInitiated'], + }, [TaskEvent.ACCEPT]: { target: TaskState.CONSULTING, + actions: ['emitTaskConsultAccepted'], + }, + [TaskEvent.CONSULT_ACCEPTED]: { + target: TaskState.CONSULTING, + actions: ['emitTaskConsultAccepted'], }, [TaskEvent.RONA]: { target: TaskState.TERMINATED, - actions: ['markEnded'], + actions: ['updateTaskData', 'markEnded', 'emitTaskReject'], }, [TaskEvent.END]: { target: TaskState.TERMINATED, - actions: ['markEnded'], + actions: ['updateTaskData', 'markEnded', 'emitTaskEnd'], + }, + [TaskEvent.DECLINE]: { + target: TaskState.TERMINATED, + actions: ['updateTaskData', 'markEnded', 'emitTaskReject'], }, }, }, [TaskState.CONNECTED]: { on: { + [TaskEvent.HOLD_INITIATED]: { + target: TaskState.HOLD_INITIATING, + actions: ['setHoldInitiated'], + }, [TaskEvent.HOLD]: { target: TaskState.HOLD_INITIATING, }, @@ -104,24 +164,36 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { }, [TaskEvent.CONSULT_CREATED]: { target: TaskState.CONSULTING, - actions: ['updateTaskData', 'setConsultInitiator'], + actions: ['updateTaskData', 'setConsultInitiator', 'emitTaskConsultCreated'], + }, + [TaskEvent.CONSULT_ACCEPTED]: { + target: TaskState.CONSULTING, + actions: ['updateTaskData', 'handleConsultAccept', 'emitTaskConsultAccepted'], }, [TaskEvent.TRANSFER]: { target: TaskState.WRAPPING_UP, + actions: ['handleTransferInit'], + }, + [TaskEvent.TRANSFER_SUCCESS]: { + target: TaskState.WRAPPING_UP, + actions: ['updateTaskData', 'markEnded', 'emitTaskEnd', 'finalizeTransfer'], + }, + [TaskEvent.TRANSFER_FAILED]: { + actions: ['updateTaskData', 'finalizeTransfer'], }, [TaskEvent.END]: { target: TaskState.WRAPPING_UP, - actions: ['markEnded'], + actions: ['updateTaskData', 'markEnded', 'emitTaskEnd'], }, [TaskEvent.CONTACT_ENDED]: { target: TaskState.WRAPPING_UP, - actions: ['markEnded'], + actions: ['updateTaskData', 'markEnded', 'emitTaskEnd'], }, [TaskEvent.PAUSE_RECORDING]: { - actions: ['setRecordingState'], + actions: ['updateTaskData', 'setRecordingState', 'emitTaskRecordingPaused'], }, [TaskEvent.RESUME_RECORDING]: { - actions: ['setRecordingState'], + actions: ['updateTaskData', 'setRecordingState', 'emitTaskRecordingResumed'], }, }, }, @@ -130,7 +202,7 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { on: { [TaskEvent.HOLD_SUCCESS]: { target: TaskState.HELD, - actions: ['setHoldState'], + actions: ['updateTaskData', 'setHoldState', 'emitTaskHold'], }, [TaskEvent.HOLD_FAILED]: { target: TaskState.CONNECTED, @@ -140,6 +212,9 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { [TaskState.HELD]: { on: { + [TaskEvent.UNHOLD_INITIATED]: { + target: TaskState.RESUME_INITIATING, + }, [TaskEvent.UNHOLD]: { target: TaskState.RESUME_INITIATING, }, @@ -149,10 +224,18 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { }, [TaskEvent.TRANSFER]: { target: TaskState.WRAPPING_UP, + actions: ['handleTransferInit'], + }, + [TaskEvent.TRANSFER_SUCCESS]: { + target: TaskState.WRAPPING_UP, + actions: ['updateTaskData', 'markEnded', 'emitTaskEnd', 'finalizeTransfer'], + }, + [TaskEvent.TRANSFER_FAILED]: { + actions: ['updateTaskData', 'finalizeTransfer'], }, [TaskEvent.END]: { target: TaskState.WRAPPING_UP, - actions: ['markEnded'], + actions: ['updateTaskData', 'markEnded', 'emitTaskEnd'], }, }, }, @@ -161,7 +244,7 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { on: { [TaskEvent.UNHOLD_SUCCESS]: { target: TaskState.CONNECTED, - actions: ['setHoldState'], + actions: ['updateTaskData', 'setHoldState', 'emitTaskResume'], }, [TaskEvent.UNHOLD_FAILED]: { target: TaskState.HELD, @@ -173,9 +256,11 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { on: { [TaskEvent.CONSULT_SUCCESS]: { target: TaskState.CONSULTING, + actions: ['handleConsultCompletion'], }, [TaskEvent.CONSULT_FAILED]: { target: TaskState.CONNECTED, + actions: ['updateTaskData', 'handleConsultFailed'], }, }, }, @@ -183,20 +268,23 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { [TaskState.CONSULTING]: { on: { [TaskEvent.CONSULTING_ACTIVE]: { - actions: ['setConsultAgentJoined'], + actions: ['updateTaskData', 'setConsultAgentJoined', 'emitTaskConsulting'], }, [TaskEvent.START_CONFERENCE]: { target: TaskState.CONFERENCING, + actions: ['handleConferenceInit'], }, [TaskEvent.MERGE_TO_CONFERENCE]: { target: TaskState.CONFERENCING, + actions: ['handleConferenceInit'], }, [TaskEvent.CONFERENCE_START]: { target: TaskState.CONFERENCING, + actions: ['handleConferenceStarted'], }, [TaskEvent.CONSULT_END]: { target: TaskState.CONNECTED, - actions: ['clearConsultState'], + actions: ['clearConsultState', 'emitTaskConsultEnd'], }, [TaskEvent.CONSULT_TRANSFER]: { target: TaskState.WRAPPING_UP, @@ -204,43 +292,56 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { }, [TaskEvent.TRANSFER]: { target: TaskState.WRAPPING_UP, + actions: ['handleTransferInit'], + }, + [TaskEvent.TRANSFER_SUCCESS]: { + target: TaskState.WRAPPING_UP, + actions: ['updateTaskData', 'markEnded', 'emitTaskEnd', 'finalizeTransfer'], + }, + [TaskEvent.TRANSFER_FAILED]: { + actions: ['updateTaskData', 'finalizeTransfer'], }, [TaskEvent.END]: { target: TaskState.WRAPPING_UP, - actions: ['markEnded', 'clearConsultState'], + actions: ['updateTaskData', 'markEnded', 'clearConsultState', 'emitTaskEnd'], }, [TaskEvent.CONTACT_ENDED]: { target: TaskState.WRAPPING_UP, - actions: ['markEnded', 'clearConsultState'], + actions: ['updateTaskData', 'markEnded', 'clearConsultState', 'emitTaskEnd'], }, }, }, [TaskState.CONFERENCING]: { on: { + [TaskEvent.CONSULT]: { + target: TaskState.CONSULT_INITIATING, + actions: ['setConsultInitiator', 'setConsultDestination'], + }, [TaskEvent.EXIT_CONFERENCE]: { target: TaskState.WRAPPING_UP, - actions: ['markEnded'], + actions: ['updateTaskData', 'markEnded', 'emitTaskEnd'], }, [TaskEvent.TRANSFER_CONFERENCE]: { target: TaskState.WRAPPING_UP, }, [TaskEvent.CONFERENCE_END]: { target: TaskState.WRAPPING_UP, - actions: ['markEnded'], + actions: ['updateTaskData', 'markEnded', 'handleConferenceFailed', 'emitTaskEnd'], }, [TaskEvent.END]: { target: TaskState.WRAPPING_UP, - actions: ['markEnded'], + actions: ['updateTaskData', 'markEnded', 'emitTaskEnd'], }, [TaskEvent.CONTACT_ENDED]: { target: TaskState.WRAPPING_UP, - actions: ['markEnded'], + actions: ['updateTaskData', 'markEnded', 'handleConferenceFailed', 'emitTaskEnd'], }, }, }, [TaskState.WRAPPING_UP]: { + entry: ['emitTaskEnd'], on: { [TaskEvent.WRAPUP]: { target: TaskState.COMPLETED, @@ -248,12 +349,16 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { [TaskEvent.AUTO_WRAPUP]: { target: TaskState.COMPLETED, }, + [TaskEvent.WRAPUP_COMPLETE]: { + target: TaskState.COMPLETED, + actions: ['updateTaskData'], + }, }, }, [TaskState.COMPLETED]: { type: 'final' as const, - entry: ['cleanupResources'], + entry: ['cleanupResources', 'emitTaskWrappedup'], }, [TaskState.TERMINATED]: { @@ -272,10 +377,16 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { * @param uiControlConfig - UI control configuration * @returns StateMachine instance for task management */ -export function createTaskStateMachine(uiControlConfig: UIControlConfig) { - return taskStateMachineSetup - .createMachine(getTaskStateMachineConfig(uiControlConfig)) - .provide({actions}); +export function createTaskStateMachine( + uiControlConfig: UIControlConfig, + options?: {actions?: Partial} +) { + return taskStateMachineSetup.createMachine(getTaskStateMachineConfig(uiControlConfig)).provide({ + actions: { + ...actions, + ...(options?.actions ?? {}), + }, + }); } export type TaskStateMachine = ReturnType; diff --git a/packages/@webex/contact-center/src/services/task/state-machine/actions.ts b/packages/@webex/contact-center/src/services/task/state-machine/actions.ts index 6045cb6ac1c..d5c5f20f1a5 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/actions.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/actions.ts @@ -19,7 +19,7 @@ import {TaskEvent, TaskState} from './constants'; import {TaskData} from '../types'; import {computeUIControls, getDefaultUIControls} from './uiControlsComputer'; -type TaskActionsMap = ActionFunctionMap< +export type TaskActionsMap = ActionFunctionMap< TaskContext, TaskEventPayload, never, @@ -122,6 +122,10 @@ export function createInitialContext( ): TaskContext { const baseContext: TaskContext = { taskData: null, + acceptInitiated: false, + holdInitiated: false, + transferInitiated: false, + conferenceInitiated: false, consultInitiator: false, consultDestination: null, consultDestinationAgentJoined: false, @@ -159,7 +163,13 @@ export const actions: TaskActionsMap = { * Initialize task with offer data */ initializeTask: assign(({context, event}: {context: TaskContext; event: TaskEventPayload}) => { - return deriveTaskDataUpdates(context, getTaskDataFromEvent(event)); + return { + acceptInitiated: false, + holdInitiated: false, + transferInitiated: false, + conferenceInitiated: false, + ...deriveTaskDataUpdates(context, getTaskDataFromEvent(event)), + }; }), /** @@ -176,6 +186,59 @@ export const actions: TaskActionsMap = { consultInitiator: true, }), + /** + * Track accept flow state + */ + setAcceptInitiated: assign({ + acceptInitiated: true, + }), + + /** + * Track hold flow state + */ + setHoldInitiated: assign({ + holdInitiated: true, + }), + + /** + * Track transfer flow state + */ + handleTransferInit: assign({ + transferInitiated: true, + }), + + finalizeTransfer: assign({ + transferInitiated: false, + }), + + /** + * Handle consult-phase callbacks + */ + handleConsultAccept: assign({ + consultDestinationAgentJoined: true, + }), + + handleConsultCompletion: assign({ + consultDestinationAgentJoined: true, + }), + + handleConsultFailed: assign({ + consultDestination: null, + consultDestinationAgentJoined: false, + }), + + handleConferenceInit: assign({ + conferenceInitiated: true, + }), + + handleConferenceStarted: assign({ + conferenceInitiated: false, + }), + + handleConferenceFailed: assign({ + conferenceInitiated: false, + }), + /** * Set consult destination details */ @@ -237,6 +300,7 @@ export const actions: TaskActionsMap = { clearConsultState: assign({ consultDestination: null, consultDestinationAgentJoined: false, + conferenceInitiated: false, }), /** @@ -282,6 +346,7 @@ export const actions: TaskActionsMap = { media: updatedMedia, }, }, + holdInitiated: false, }; }), @@ -299,6 +364,31 @@ export const actions: TaskActionsMap = { cleanupResources: () => { return undefined; }, + + /** + * Placeholder emitters that get overridden by consumers when needed + * These are invoked by the state machine to trigger task events + */ + emitTaskHydrate: () => undefined, + emitTaskOfferContact: () => undefined, + emitTaskAssigned: () => undefined, + emitTaskHold: () => undefined, + emitTaskResume: () => undefined, + emitTaskEnd: () => undefined, + emitTaskOfferConsult: () => undefined, + emitTaskConsultCreated: () => undefined, + emitTaskConsulting: () => undefined, + emitTaskConsultAccepted: () => undefined, + emitTaskConsultEnd: () => undefined, + emitTaskConsultQueueCancelled: () => undefined, + emitTaskConsultQueueFailed: () => undefined, + emitTaskReject: () => undefined, + emitTaskRecordingStarted: () => undefined, + emitTaskRecordingPaused: () => undefined, + emitTaskRecordingPauseFailed: () => undefined, + emitTaskRecordingResumed: () => undefined, + emitTaskRecordingResumeFailed: () => undefined, + emitTaskWrappedup: () => undefined, }; /** diff --git a/packages/@webex/contact-center/src/services/task/state-machine/constants.ts b/packages/@webex/contact-center/src/services/task/state-machine/constants.ts index 44ef48fb90c..661fa652d0b 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/constants.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/constants.ts @@ -13,7 +13,6 @@ export enum TaskState { HOLD_INITIATING = 'HOLD_INITIATING', HELD = 'HELD', RESUME_INITIATING = 'RESUME_INITIATING', - CONSULT_INITIATING = 'CONSULT_INITIATING', CONSULTING = 'CONSULTING', @@ -34,12 +33,18 @@ export enum TaskState { } export enum TaskEvent { + TASK_INCOMING = 'TASK_INCOMING', + TASK_OFFERED = 'TASK_OFFERED', + // Offer events OFFER = 'OFFER', + OFFER_CONTACT = 'OFFER_CONTACT', OFFER_CONSULT = 'OFFER_CONSULT', + HYDRATE = 'HYDRATE', // Assignment events ACCEPT = 'ACCEPT', + ACCEPT_INITIATED = 'ACCEPT_INITIATED', DECLINE = 'DECLINE', ASSIGN = 'ASSIGN', @@ -50,6 +55,8 @@ export enum TaskEvent { UNHOLD = 'UNHOLD', UNHOLD_SUCCESS = 'UNHOLD_SUCCESS', UNHOLD_FAILED = 'UNHOLD_FAILED', + HOLD_INITIATED = 'HOLD_INITIATED', + UNHOLD_INITIATED = 'UNHOLD_INITIATED', // Consult events CONSULT = 'CONSULT', @@ -59,6 +66,7 @@ export enum TaskEvent { CONSULT_END = 'CONSULT_END', CONSULT_TRANSFER = 'CONSULT_TRANSFER', CONSULT_FAILED = 'CONSULT_FAILED', + CONSULT_ACCEPTED = 'CONSULT_ACCEPTED', // Conference events START_CONFERENCE = 'START_CONFERENCE', @@ -77,6 +85,8 @@ export enum TaskEvent { // Transfer events TRANSFER = 'TRANSFER', + TRANSFER_SUCCESS = 'TRANSFER_SUCCESS', + TRANSFER_FAILED = 'TRANSFER_FAILED', // Wrapup events WRAPUP_START = 'WRAPUP_START', @@ -92,9 +102,11 @@ export enum TaskEvent { // Failure events ASSIGN_FAILED = 'ASSIGN_FAILED', INVITE_FAILED = 'INVITE_FAILED', + OUTBOUND_FAILED = 'OUTBOUND_FAILED', // Queue events CTQ_CANCEL = 'CTQ_CANCEL', // Cancel To Queue + CTQ_CANCEL_FAILED = 'CTQ_CANCEL_FAILED', } export enum TaskAction { diff --git a/packages/@webex/contact-center/src/services/task/state-machine/types.ts b/packages/@webex/contact-center/src/services/task/state-machine/types.ts index d644e779c29..570206426ed 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/types.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/types.ts @@ -70,6 +70,10 @@ export interface TaskContext { taskData: TaskData | null; // Consult tracking + acceptInitiated: boolean; + holdInitiated: boolean; + transferInitiated: boolean; + conferenceInitiated: boolean; consultInitiator: boolean; consultDestination: string | null; consultDestinationAgentJoined: boolean; @@ -94,19 +98,32 @@ type BaseEvent = {type: T}; * Event payload mapping - defines the payload for each event type */ interface TaskEventPayloadMap { + [TaskEvent.TASK_INCOMING]: BaseEvent & {taskData: TaskData}; + [TaskEvent.TASK_OFFERED]: BaseEvent & {taskData: TaskData}; [TaskEvent.OFFER]: BaseEvent & {taskData: TaskData}; + [TaskEvent.OFFER_CONTACT]: BaseEvent & {taskData: TaskData}; [TaskEvent.OFFER_CONSULT]: BaseEvent & {taskData: TaskData}; + [TaskEvent.HYDRATE]: BaseEvent & {taskData: TaskData}; [TaskEvent.ACCEPT]: BaseEvent; + [TaskEvent.ACCEPT_INITIATED]: BaseEvent; [TaskEvent.DECLINE]: BaseEvent; [TaskEvent.ASSIGN]: BaseEvent & {taskData: TaskData}; [TaskEvent.HOLD]: BaseEvent & {mediaResourceId: string}; - [TaskEvent.HOLD_SUCCESS]: BaseEvent & {mediaResourceId: string}; + [TaskEvent.HOLD_INITIATED]: BaseEvent & {mediaResourceId: string}; + [TaskEvent.HOLD_SUCCESS]: BaseEvent & { + mediaResourceId: string; + taskData?: TaskData; + }; [TaskEvent.HOLD_FAILED]: BaseEvent & { reason?: string; mediaResourceId: string; }; [TaskEvent.UNHOLD]: BaseEvent & {mediaResourceId: string}; - [TaskEvent.UNHOLD_SUCCESS]: BaseEvent & {mediaResourceId: string}; + [TaskEvent.UNHOLD_INITIATED]: BaseEvent & {mediaResourceId: string}; + [TaskEvent.UNHOLD_SUCCESS]: BaseEvent & { + mediaResourceId: string; + taskData?: TaskData; + }; [TaskEvent.UNHOLD_FAILED]: BaseEvent & { reason?: string; mediaResourceId: string; @@ -119,10 +136,15 @@ interface TaskEventPayloadMap { [TaskEvent.CONSULT_CREATED]: BaseEvent & {taskData: TaskData}; [TaskEvent.CONSULTING_ACTIVE]: BaseEvent & { consultDestinationAgentJoined: boolean; + taskData?: TaskData; }; - [TaskEvent.CONSULT_END]: BaseEvent; + [TaskEvent.CONSULT_END]: BaseEvent & {taskData?: TaskData}; [TaskEvent.CONSULT_TRANSFER]: BaseEvent; - [TaskEvent.CONSULT_FAILED]: BaseEvent & {reason?: string}; + [TaskEvent.CONSULT_FAILED]: BaseEvent & { + reason?: string; + taskData?: TaskData; + }; + [TaskEvent.CONSULT_ACCEPTED]: BaseEvent & {taskData?: TaskData}; [TaskEvent.START_CONFERENCE]: BaseEvent; [TaskEvent.MERGE_TO_CONFERENCE]: BaseEvent; [TaskEvent.CONFERENCE_START]: BaseEvent & { @@ -136,19 +158,26 @@ interface TaskEventPayloadMap { [TaskEvent.PARTICIPANT_LEAVE]: BaseEvent & {participantId: string}; [TaskEvent.EXIT_CONFERENCE]: BaseEvent & {agentId?: string}; [TaskEvent.RECORDING_STARTED]: BaseEvent & {taskData: TaskData}; - [TaskEvent.PAUSE_RECORDING]: BaseEvent; - [TaskEvent.RESUME_RECORDING]: BaseEvent; + [TaskEvent.PAUSE_RECORDING]: BaseEvent & {taskData: TaskData}; + [TaskEvent.RESUME_RECORDING]: BaseEvent & {taskData: TaskData}; [TaskEvent.TRANSFER]: BaseEvent; + [TaskEvent.TRANSFER_SUCCESS]: BaseEvent & {taskData?: TaskData}; + [TaskEvent.TRANSFER_FAILED]: BaseEvent & { + reason?: string; + taskData?: TaskData; + }; [TaskEvent.WRAPUP_START]: BaseEvent; [TaskEvent.WRAPUP]: BaseEvent & {wrapupData?: any}; - [TaskEvent.WRAPUP_COMPLETE]: BaseEvent; - [TaskEvent.END]: BaseEvent; - [TaskEvent.RONA]: BaseEvent; - [TaskEvent.CONTACT_ENDED]: BaseEvent; + [TaskEvent.WRAPUP_COMPLETE]: BaseEvent & {taskData?: TaskData}; + [TaskEvent.END]: BaseEvent & {taskData?: TaskData}; + [TaskEvent.RONA]: BaseEvent & {taskData?: TaskData; reason?: string}; + [TaskEvent.CONTACT_ENDED]: BaseEvent & {taskData: TaskData}; [TaskEvent.AUTO_WRAPUP]: BaseEvent; [TaskEvent.ASSIGN_FAILED]: BaseEvent & {reason?: string}; [TaskEvent.INVITE_FAILED]: BaseEvent & {reason?: string}; - [TaskEvent.CTQ_CANCEL]: BaseEvent; + [TaskEvent.OUTBOUND_FAILED]: BaseEvent & {reason?: string}; + [TaskEvent.CTQ_CANCEL]: BaseEvent & {taskData: TaskData}; + [TaskEvent.CTQ_CANCEL_FAILED]: BaseEvent & {taskData: TaskData}; } /** diff --git a/packages/@webex/contact-center/src/services/task/voice/Voice.ts b/packages/@webex/contact-center/src/services/task/voice/Voice.ts index 5250a8aa440..d7092c37896 100644 --- a/packages/@webex/contact-center/src/services/task/voice/Voice.ts +++ b/packages/@webex/contact-center/src/services/task/voice/Voice.ts @@ -13,9 +13,10 @@ import { ConsultTransferPayLoad, CONSULT_TRANSFER_DESTINATION_TYPE, TASK_CHANNEL_TYPE, + TASK_EVENTS, VOICE_VARIANT, } from '../types'; -import Task from '../Task'; +import Task, {TaskRuntimeOptions} from '../Task'; import LoggerProxy from '../../../logger-proxy'; import MetricsManager from '../../../metrics/MetricsManager'; import {METRIC_EVENT_NAMES} from '../../../metrics/constants'; @@ -25,15 +26,21 @@ export default class Voice extends Task implements IVoice { constructor( contact: ReturnType, data: TaskData, - callOptions: VoiceUIControlOptions = {} + callOptions: VoiceUIControlOptions = {}, + runtimeOptions: TaskRuntimeOptions = {} ) { - super(contact, data, { - channelType: TASK_CHANNEL_TYPE.VOICE, - isEndTaskEnabled: callOptions.isEndTaskEnabled ?? true, - isEndConsultEnabled: callOptions.isEndConsultEnabled ?? true, - voiceVariant: callOptions.voiceVariant ?? VOICE_VARIANT.PSTN, - isRecordingEnabled: callOptions.isRecordingEnabled ?? true, - }); + super( + contact, + data, + { + channelType: TASK_CHANNEL_TYPE.VOICE, + isEndTaskEnabled: callOptions.isEndTaskEnabled ?? true, + isEndConsultEnabled: callOptions.isEndConsultEnabled ?? true, + voiceVariant: callOptions.voiceVariant ?? VOICE_VARIANT.PSTN, + isRecordingEnabled: callOptions.isRecordingEnabled ?? true, + }, + runtimeOptions + ); } /** @@ -627,4 +634,31 @@ export default class Voice extends Task implements IVoice { throw detailedError; } } + + protected override getChannelSpecificActionOverrides() { + const baseOverrides = super.getChannelSpecificActionOverrides(); + + return { + ...baseOverrides, + emitTaskHold: this.createEmitSelfAction(TASK_EVENTS.TASK_HOLD, {updateTaskData: true}), + emitTaskResume: this.createEmitSelfAction(TASK_EVENTS.TASK_RESUME, {updateTaskData: true}), + emitTaskRecordingStarted: this.createEmitSelfAction(TASK_EVENTS.TASK_RECORDING_STARTED, { + updateTaskData: true, + }), + emitTaskRecordingPaused: this.createEmitSelfAction(TASK_EVENTS.TASK_RECORDING_PAUSED, { + updateTaskData: true, + }), + emitTaskRecordingPauseFailed: this.createEmitSelfAction( + TASK_EVENTS.TASK_RECORDING_PAUSE_FAILED, + {updateTaskData: true} + ), + emitTaskRecordingResumed: this.createEmitSelfAction(TASK_EVENTS.TASK_RECORDING_RESUMED, { + updateTaskData: true, + }), + emitTaskRecordingResumeFailed: this.createEmitSelfAction( + TASK_EVENTS.TASK_RECORDING_RESUME_FAILED, + {updateTaskData: true} + ), + }; + } } diff --git a/packages/@webex/contact-center/src/services/task/voice/WebRTC.ts b/packages/@webex/contact-center/src/services/task/voice/WebRTC.ts index 116b1c1091f..0edd767204a 100644 --- a/packages/@webex/contact-center/src/services/task/voice/WebRTC.ts +++ b/packages/@webex/contact-center/src/services/task/voice/WebRTC.ts @@ -11,6 +11,7 @@ import { VOICE_VARIANT, } from '../types'; import Voice from './Voice'; +import type {TaskRuntimeOptions} from '../Task'; import WebCallingService from '../../WebCallingService'; import MetricsManager from '../../../metrics/MetricsManager'; import {METRIC_EVENT_NAMES} from '../../../metrics/constants'; @@ -24,9 +25,10 @@ export default class WebRTC extends Voice implements IWebRTC { contact: ReturnType, webCallingService: WebCallingService, data: TaskData, - callOptions: VoiceUIControlOptions = {} + callOptions: VoiceUIControlOptions = {}, + runtimeOptions: TaskRuntimeOptions = {} ) { - super(contact, data, {...callOptions, voiceVariant: VOICE_VARIANT.WEBRTC}); + super(contact, data, {...callOptions, voiceVariant: VOICE_VARIANT.WEBRTC}, runtimeOptions); this.webCallingService = webCallingService; this.registerWebCallListeners(); } 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 6019fde155b..8bf2ac4be43 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 @@ -252,6 +252,54 @@ describe('TaskManager', () => { expect(taskManager.getAllTasks()).toHaveProperty(payload.data.interactionId); }); + it('should send mapped events through the state machine without duplicate updates', () => { + webSocketManagerMock.emit('message', JSON.stringify(initalPayload)); + const task = taskManager.getTask(taskId); + const updateSpy = jest.spyOn(task, 'updateTaskData'); + const sendSpy = jest.spyOn(taskManager as any, 'sendEventToStateMachine'); + const cleanupSpy = jest.spyOn(taskManager as any, 'handleTaskCleanup'); + + const assignFailedPayload = { + data: { + ...initalPayload.data, + type: CC_EVENTS.AGENT_CONTACT_ASSIGN_FAILED, + reason: 'ASSIGN_FAILED', + }, + }; + + webSocketManagerMock.emit('message', JSON.stringify(assignFailedPayload)); + + expect(sendSpy).toHaveBeenCalled(); + const [, , , stateMachineEvent] = sendSpy.mock.calls[sendSpy.mock.calls.length - 1]; + expect(stateMachineEvent).toEqual({ + type: TaskEvent.ASSIGN_FAILED, + taskData: assignFailedPayload.data, + }); + expect(updateSpy).not.toHaveBeenCalled(); + expect(cleanupSpy).toHaveBeenCalledWith(task); + }); + + it('should update task data directly when no state machine mapping exists', () => { + webSocketManagerMock.emit('message', JSON.stringify(initalPayload)); + const task = taskManager.getTask(taskId); + const updateSpy = jest.spyOn(task, 'updateTaskData'); + const sendSpy = jest.spyOn(taskManager as any, 'sendEventToStateMachine'); + + const participantMovedPayload = { + data: { + ...initalPayload.data, + type: CC_EVENTS.CONSULTED_PARTICIPANT_MOVING, + }, + }; + + webSocketManagerMock.emit('message', JSON.stringify(participantMovedPayload)); + + expect(sendSpy).toHaveBeenCalled(); + const [, , , stateMachineEvent] = sendSpy.mock.calls[sendSpy.mock.calls.length - 1]; + expect(stateMachineEvent).toBeUndefined(); + expect(updateSpy).toHaveBeenCalledWith(participantMovedPayload.data); + }); + it('should return task by ID', () => { const taskId = 'task123'; const mockTask = { diff --git a/yarn.lock b/yarn.lock index 45cefb31eb5..c62dbb5d948 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9071,6 +9071,7 @@ __metadata: prettier: 2.5.1 typedoc: ^0.25.0 typescript: 5.4.5 + uuid: ^3.3.2 xstate: 5.24.0 languageName: unknown linkType: soft From abcd2fd3b306971d6c1382dc538762e3cdde9b1f Mon Sep 17 00:00:00 2001 From: arungane Date: Fri, 12 Dec 2025 10:49:42 +0530 Subject: [PATCH 14/14] feat(contact-center): wrapping up with state machine --- docs/samples/contact-center/app.js | 4 +- .../contact-center/src/services/core/Utils.ts | 88 +++--- .../contact-center/src/services/task/Task.ts | 77 ++--- .../src/services/task/TaskManager.ts | 94 +++--- .../contact-center/src/services/task/index.ts | 8 +- .../task/state-machine/TaskStateMachine.ts | 267 ++++++++++++++---- .../services/task/state-machine/actions.ts | 35 ++- .../services/task/state-machine/constants.ts | 4 + .../src/services/task/state-machine/types.ts | 3 + .../task/state-machine/uiControlsComputer.ts | 9 +- .../contact-center/src/services/task/types.ts | 25 +- .../src/services/task/voice/Voice.ts | 80 +++++- .../test/unit/spec/services/task/Task.ts | 22 +- 13 files changed, 514 insertions(+), 202 deletions(-) diff --git a/docs/samples/contact-center/app.js b/docs/samples/contact-center/app.js index a6dcdf14d90..3f3be9aa11b 100644 --- a/docs/samples/contact-center/app.js +++ b/docs/samples/contact-center/app.js @@ -692,8 +692,8 @@ async function initiateConsultTransfer() { if (currentTask.data.isConferenceInProgress) { await currentTask.transferConference(); } else { - await currentTask.consultTransfer(consultTransferPayload); - console.log('Consult transfer initiated successfully'); + await currentTask.transfer(consultTransferPayload); + console.log('Consult/regular transfer initiated successfully'); } } catch (error) { console.error('Failed to initiate consult transfer', error); diff --git a/packages/@webex/contact-center/src/services/core/Utils.ts b/packages/@webex/contact-center/src/services/core/Utils.ts index 3e182007425..7e7ab36da5a 100644 --- a/packages/@webex/contact-center/src/services/core/Utils.ts +++ b/packages/@webex/contact-center/src/services/core/Utils.ts @@ -4,12 +4,13 @@ import {Failure, AugmentedError} from './GlobalTypes'; import LoggerProxy from '../../logger-proxy'; import WebexRequest from './WebexRequest'; import { - TaskData, - ConsultTransferPayLoad, ConsultConferenceData, consultConferencePayloadData, + ConsultTransferDestinationType, CONSULT_TRANSFER_DESTINATION_TYPE, + DESTINATION_TYPE, Interaction, + TaskData, } from '../task/types'; /** @@ -27,28 +28,6 @@ const getCommonErrorDetails = (errObj: WebexRequestPayload) => { }; }; -/** - * Checks if the destination type represents an entry point variant (EPDN or ENTRYPOINT). - */ -const isEntryPointOrEpdn = (destAgentType?: string): boolean => { - return destAgentType === 'EPDN' || destAgentType === 'ENTRYPOINT'; -}; - -/** - * Determines if the task involves dialing a number based on the destination type. - * Returns 'DIAL_NUMBER' for dial-related destinations, empty string otherwise. - */ -const getAgentActionTypeFromTask = (taskData?: TaskData): 'DIAL_NUMBER' | '' => { - const destAgentType = taskData?.destinationType; - - // Check if destination requires dialing: direct dial number or entry point variants - const isDialNumber = destAgentType === 'DN'; - const isEntryPointVariant = isEntryPointOrEpdn(destAgentType); - - // If the destination type is a dial number or an entry point variant, return 'DIAL_NUMBER' - return isDialNumber || isEntryPointVariant ? 'DIAL_NUMBER' : ''; -}; - export const isValidDialNumber = (input: string): boolean => { // This regex checks for a valid dial number format for only few countries such as US, Canada. const regexForDn = /1[0-9]{3}[2-9][0-9]{6}([,]{1,10}[0-9]+){0,1}/; @@ -217,18 +196,6 @@ export const createErrDetailsObject = (errObj: WebexRequestPayload) => { return new Err.Details('Service.reqs.generic.failure', details); }; -/** - * Derives the consult transfer destination type based on the provided task data. - * - * Logic parity with desktop behavior: - * - If agent action is dialing a number (DN/EPDN/ENTRYPOINT): - * - ENTRYPOINT/EPDN map to ENTRYPOINT - * - DN maps to DIALNUMBER - * - Otherwise defaults to AGENT - * - * @param taskData - The task data used to infer the agent action and destination type - * @returns The normalized destination type to be used for consult transfer - */ /** * Checks if a participant type represents a non-customer participant. * Non-customer participants include agents, dial numbers, entry point dial numbers, @@ -273,20 +240,6 @@ export const getDestinationAgentId = ( return id; }; -export const deriveConsultTransferDestinationType = ( - taskData?: TaskData -): ConsultTransferPayLoad['destinationType'] => { - const agentActionType = getAgentActionTypeFromTask(taskData); - - if (agentActionType === 'DIAL_NUMBER') { - return isEntryPointOrEpdn(taskData?.destinationType) - ? CONSULT_TRANSFER_DESTINATION_TYPE.ENTRYPOINT - : CONSULT_TRANSFER_DESTINATION_TYPE.DIALNUMBER; - } - - return CONSULT_TRANSFER_DESTINATION_TYPE.AGENT; -}; - /** * Builds consult conference parameter data using EXACT Agent Desktop logic. * This matches the Agent Desktop's consultConference implementation exactly. @@ -311,16 +264,16 @@ export const buildConsultConferenceParamData = ( // Agent Desktop destination type logic if ('destinationType' in dataPassed) { if (dataPassed.destinationType === 'DN') { - data.destinationType = CONSULT_TRANSFER_DESTINATION_TYPE.DIALNUMBER; + data.destinationType = DESTINATION_TYPE.DIALNUMBER; } else if (dataPassed.destinationType === 'EP_DN') { - data.destinationType = CONSULT_TRANSFER_DESTINATION_TYPE.ENTRYPOINT; + data.destinationType = DESTINATION_TYPE.ENTRYPOINT; } else { // Keep the existing destinationType if it's something else (like "agent" or "Agent") // Convert "Agent" to lowercase for consistency data.destinationType = dataPassed.destinationType.toLowerCase(); } } else { - data.destinationType = CONSULT_TRANSFER_DESTINATION_TYPE.AGENT; + data.destinationType = DESTINATION_TYPE.AGENT; } return { @@ -328,3 +281,32 @@ export const buildConsultConferenceParamData = ( data, }; }; + +/** + * Derives the consult transfer destination type based on task data. + * This function determines the appropriate destination type for a consult transfer + * by examining the destination type stored in the task data. + * + * @param taskData - The task data containing destination information + * @returns The derived consult transfer destination type + * @public + */ +export const deriveConsultTransferDestinationType = ( + taskData: TaskData +): ConsultTransferDestinationType => { + const destType = taskData?.destinationType; + + // Map destination types to consult transfer destination types + if (destType === 'DN' || destType === DESTINATION_TYPE.DIALNUMBER) { + return CONSULT_TRANSFER_DESTINATION_TYPE.DIALNUMBER; + } + if (destType === 'EP_DN' || destType === DESTINATION_TYPE.ENTRYPOINT) { + return CONSULT_TRANSFER_DESTINATION_TYPE.ENTRYPOINT; + } + if (destType === DESTINATION_TYPE.QUEUE) { + return CONSULT_TRANSFER_DESTINATION_TYPE.QUEUE; + } + + // Default to agent if no specific type matches + return CONSULT_TRANSFER_DESTINATION_TYPE.AGENT; +}; diff --git a/packages/@webex/contact-center/src/services/task/Task.ts b/packages/@webex/contact-center/src/services/task/Task.ts index 62ad0b651c7..519d67b178a 100644 --- a/packages/@webex/contact-center/src/services/task/Task.ts +++ b/packages/@webex/contact-center/src/services/task/Task.ts @@ -13,6 +13,7 @@ import { TaskUIControls, } from './types'; import {CC_FILE} from '../../constants'; +import {TASK_FILE} from './constants'; import {getErrorDetails} from '../core/Utils'; import routingContact from './contact'; import MetricsManager from '../../metrics/MetricsManager'; @@ -33,17 +34,12 @@ import { } from './state-machine/uiControlsComputer'; import type {TaskActionsMap} from './state-machine/actions'; -type CallId = string; - -export interface TaskActionCallbacks { - onTaskHydrated?: (task: ITask, taskData: TaskData) => void; - onTaskOffered?: (task: ITask, taskData: TaskData) => void; -} - export interface TaskRuntimeOptions { - actionCallbacks?: TaskActionCallbacks; + actionOverrides?: Partial; } +type CallId = string; + export default abstract class Task extends EventEmitter implements ITask { protected contact: ReturnType; protected metricsManager: MetricsManager; @@ -54,7 +50,7 @@ export default abstract class Task extends EventEmitter implements ITask { private lastState?: TaskState; protected currentUiControls: TaskUIControls; protected uiControlConfig: UIControlConfig; - protected actionCallbacks?: TaskActionCallbacks; + protected runtimeOptions: TaskRuntimeOptions; constructor( contact: ReturnType, @@ -66,10 +62,10 @@ export default abstract class Task extends EventEmitter implements ITask { this.contact = contact; this.data = data; this.uiControlConfig = uiControlConfig; - this.actionCallbacks = runtimeOptions.actionCallbacks; this.metricsManager = MetricsManager.getInstance(); this.webCallMap = {}; this.currentUiControls = getDefaultUIControls(); + this.runtimeOptions = runtimeOptions; this.initializeStateMachine(); } @@ -192,6 +188,8 @@ export default abstract class Task extends EventEmitter implements ITask { LoggerProxy.log(`State machine transition: ${previousState || 'N/A'} -> ${currentState}`, { module: CC_FILE, method: 'onTransition', + // @ts-ignore - snapshot may include event detail depending on XState version + eventType: (snapshot as any)?.event?.type, }); this.lastState = currentState; this.state = snapshot; @@ -209,6 +207,11 @@ export default abstract class Task extends EventEmitter implements ITask { */ protected sendStateMachineEvent(event: TaskEventPayload): void { if (this.stateMachineService) { + LoggerProxy.log(`Sending state machine event: ${event?.type}`, { + module: CC_FILE, + method: 'sendStateMachineEvent', + interactionId: this.data?.interactionId, + }); this.stateMachineService.send(event); } } @@ -248,7 +251,7 @@ export default abstract class Task extends EventEmitter implements ITask { } } - private extractTaskDataFromEvent(event?: TaskEventPayload): TaskData | undefined { + private static extractTaskDataFromEvent(event?: TaskEventPayload): TaskData | undefined { if (!event || typeof event !== 'object') { return undefined; } @@ -261,7 +264,7 @@ export default abstract class Task extends EventEmitter implements ITask { } private updateTaskFromEvent(event?: TaskEventPayload): void { - const taskData = this.extractTaskDataFromEvent(event); + const taskData = Task.extractTaskDataFromEvent(event); if (taskData) { this.updateTaskData(taskData); } @@ -275,7 +278,7 @@ export default abstract class Task extends EventEmitter implements ITask { } protected getChannelSpecificActionOverrides(): Partial { - return {}; + return this.runtimeOptions.actionOverrides ?? {}; } protected createEmitSelfAction( @@ -286,36 +289,26 @@ export default abstract class Task extends EventEmitter implements ITask { if (updateTaskData) { this.updateTaskFromEvent(event); } + LoggerProxy.info(`Emitting task event ${taskEvent}`, { + module: TASK_FILE, + method: 'emitTaskEvent', + interactionId: this.data?.interactionId, + }); this.emit(taskEvent, this); }; } private getCommonActionOverrides(): Partial { return { - emitTaskHydrate: ({event}: {event: TaskEventPayload}) => { - const taskData = this.extractTaskDataFromEvent(event); - if (!taskData) { - return; - } - if (this.actionCallbacks?.onTaskHydrated) { - this.actionCallbacks.onTaskHydrated(this, taskData); - } else { - this.updateTaskData(taskData); - this.emit(TASK_EVENTS.TASK_HYDRATE, this); - } - }, - emitTaskOfferContact: ({event}: {event: TaskEventPayload}) => { - const taskData = this.extractTaskDataFromEvent(event); - if (!taskData) { - return; - } - if (this.actionCallbacks?.onTaskOffered) { - this.actionCallbacks.onTaskOffered(this, taskData); - } else { - this.updateTaskData(taskData); - this.emit(TASK_EVENTS.TASK_OFFER_CONTACT, this); - } - }, + emitTaskIncoming: this.createEmitSelfAction(TASK_EVENTS.TASK_INCOMING, { + updateTaskData: true, + }), + emitTaskHydrate: this.createEmitSelfAction(TASK_EVENTS.TASK_HYDRATE, { + updateTaskData: true, + }), + emitTaskOfferContact: this.createEmitSelfAction(TASK_EVENTS.TASK_OFFER_CONTACT, { + updateTaskData: true, + }), emitTaskAssigned: this.createEmitSelfAction(TASK_EVENTS.TASK_ASSIGNED, { updateTaskData: true, }), @@ -355,6 +348,16 @@ export default abstract class Task extends EventEmitter implements ITask { : undefined; this.emit(TASK_EVENTS.TASK_REJECT, reason); }, + emitTaskWrapup: () => { + if (this.data?.wrapUpRequired) { + LoggerProxy.info(`Emitting task event ${TASK_EVENTS.TASK_WRAPUP}`, { + module: TASK_FILE, + method: 'emitTaskEvent', + interactionId: this.data?.interactionId, + }); + this.emit(TASK_EVENTS.TASK_WRAPUP, this); + } + }, emitTaskWrappedup: this.createEmitSelfAction(TASK_EVENTS.TASK_WRAPPEDUP, { updateTaskData: true, }), diff --git a/packages/@webex/contact-center/src/services/task/TaskManager.ts b/packages/@webex/contact-center/src/services/task/TaskManager.ts index 8e5134e56a4..fc93b83dde1 100644 --- a/packages/@webex/contact-center/src/services/task/TaskManager.ts +++ b/packages/@webex/contact-center/src/services/task/TaskManager.ts @@ -6,7 +6,7 @@ import WebCallingService from '../WebCallingService'; import {ITask, MEDIA_CHANNEL, TASK_EVENTS, TaskData, TaskId} from './types'; import {TASK_MANAGER_FILE} from '../../constants'; import {METHODS} from './constants'; -import {CC_EVENTS, CC_TASK_EVENTS, WrapupData} from '../config/types'; +import {CC_EVENTS, WrapupData} from '../config/types'; import {ConfigFlags, LoginOption} from '../../types'; import LoggerProxy from '../../logger-proxy'; import MetricsManager from '../../metrics/MetricsManager'; @@ -15,7 +15,7 @@ import TaskFactory from './TaskFactory'; import WebRTC from './voice/WebRTC'; import {TaskEvent, type TaskEventPayload} from './state-machine'; import {normalizeTaskData} from './taskDataNormalizer'; -import type {TaskActionCallbacks, TaskRuntimeOptions} from './Task'; +import type {TaskRuntimeOptions} from './Task'; type WebSocketPayload = TaskData & { type: CC_EVENTS | string; @@ -82,7 +82,7 @@ export default class TaskManager extends EventEmitter { private wrapupData: WrapupData; private agentId: string; private configFlags?: ConfigFlags; - private taskActionCallbacks: TaskActionCallbacks; + private readonly defaultTaskRuntimeOptions: TaskRuntimeOptions = {}; /** * @param contact - Routing Contact layer. Talks to AQMReq layer to convert events to promises * @param webCallingService - Webrtc Service Layer @@ -99,7 +99,6 @@ export default class TaskManager extends EventEmitter { this.webSocketManager = webSocketManager; this.taskCollection = {}; this.metricsManager = MetricsManager.getInstance(); - this.taskActionCallbacks = this.createTaskActionCallbacks(); this.registerTaskListeners(); this.registerIncomingCallEvent(); } @@ -133,7 +132,13 @@ export default class TaskManager extends EventEmitter { method: METHODS.HANDLE_INCOMING_WEB_CALL, interactionId: currentTask.data.interactionId, }); - this.emit(TASK_EVENTS.TASK_INCOMING, currentTask); + + // Send TASK_INCOMING to state machine - it will emit on the task object + this.sendEventToStateMachine( + CC_EVENTS.AGENT_CONTACT_RESERVED, + currentTask.data as WebSocketPayload, + currentTask + ); } this.call = call; }; @@ -207,6 +212,7 @@ export default class TaskManager extends EventEmitter { }; case CC_EVENTS.AGENT_CONSULTING: + // use context to figure out if its initatiore or the receiver using consultInitiator from context return { type: TaskEvent.CONSULTING_ACTIVE, consultDestinationAgentJoined: true, @@ -225,6 +231,9 @@ export default class TaskManager extends EventEmitter { case CC_EVENTS.AGENT_CTQ_CANCEL_FAILED: return {type: TaskEvent.CTQ_CANCEL_FAILED, taskData: payload}; + case CC_EVENTS.AGENT_CONSULT_TRANSFERRED: + return {type: TaskEvent.TRANSFER_SUCCESS, taskData: payload}; + case CC_EVENTS.AGENT_VTEAM_TRANSFERRED: case CC_EVENTS.AGENT_WRAPUP: case CC_EVENTS.AGENT_CONTACT_UNASSIGNED: @@ -301,6 +310,7 @@ export default class TaskManager extends EventEmitter { module: TASK_MANAGER_FILE, method: 'sendEventToStateMachine', interactionId: payload.interactionId, + agentId: this.agentId, }); // Send event to task's state machine using the protected method @@ -367,6 +377,7 @@ export default class TaskManager extends EventEmitter { module: TASK_MANAGER_FILE, method: 'parseWebSocketMessage', error, + agentId: this.agentId, }); return null; @@ -465,6 +476,7 @@ export default class TaskManager extends EventEmitter { this.getTaskRuntimeOptions() ); + this.setupTaskListeners(task); this.taskCollection[payload.interactionId] = task; // For telephony in-browser, we need to wait for the incoming call event @@ -500,6 +512,7 @@ export default class TaskManager extends EventEmitter { this.configFlags, this.getTaskRuntimeOptions() ); + this.setupTaskListeners(task); this.taskCollection[payload.interactionId] = task; } @@ -520,6 +533,7 @@ export default class TaskManager extends EventEmitter { module: TASK_MANAGER_FILE, method: 'handleOutboundFailed', interactionId: payload?.interactionId, + agentId: this.agentId, }); return {task, shouldRemoveFromCollection: true}; @@ -578,6 +592,13 @@ export default class TaskManager extends EventEmitter { const {task} = context; if (task) { + LoggerProxy.log('Contact ended event processed', { + module: TASK_MANAGER_FILE, + method: 'handleContactEnded', + interactionId: task.data?.interactionId, + agentId: this.agentId, + }); + return {task, shouldCleanupTask: true}; } @@ -594,7 +615,14 @@ export default class TaskManager extends EventEmitter { const {task, wasConsultedTask} = context; if (task && wasConsultedTask) { + LoggerProxy.log('Consult ended event processed', { + module: TASK_MANAGER_FILE, + method: 'handleConsultEnded', + interactionId: task.data?.interactionId, + agentId: this.agentId, + }); // End state for task if we were offered the consult + return {task, shouldRemoveFromCollection: true}; } @@ -614,6 +642,13 @@ export default class TaskManager extends EventEmitter { const {task} = context; if (task) { + LoggerProxy.log('Wrap-up complete event processed', { + module: TASK_MANAGER_FILE, + method: 'handleWrapupComplete', + interactionId: task.data?.interactionId, + agentId: this.agentId, + }); + return { task, shouldCancelAutoWrapup: true, @@ -671,11 +706,6 @@ export default class TaskManager extends EventEmitter { const {eventType, payload, stateMachineEvent} = context; - // Emit task-specific events for backward compatibility - if (Object.values(CC_TASK_EVENTS).includes(eventType as any)) { - task.emit(eventType as any, payload); - } - // Send event to state machine - this will trigger all TASK_EVENTS emissions // including TASK_INCOMING which is now handled via the state machine callbacks this.sendEventToStateMachine(eventType, payload, task, stateMachineEvent); @@ -739,31 +769,29 @@ export default class TaskManager extends EventEmitter { } private getTaskRuntimeOptions(): TaskRuntimeOptions { - return { - actionCallbacks: this.taskActionCallbacks, - }; + return this.defaultTaskRuntimeOptions; } - private createTaskActionCallbacks(): TaskActionCallbacks { - return { - onTaskHydrated: (task, taskData) => { - if (taskData) { - this.updateTaskData(task, taskData); - } - this.emit(TASK_EVENTS.TASK_HYDRATE, task); - }, - onTaskOffered: (task, taskData) => { - LoggerProxy.log(`Agent offer contact received for task`, { - module: TASK_MANAGER_FILE, - method: METHODS.REGISTER_TASK_LISTENERS, - interactionId: taskData?.interactionId, - }); - if (taskData) { - this.updateTaskData(task, taskData); - } - this.emit(TASK_EVENTS.TASK_OFFER_CONTACT, task); - }, - }; + /** + * Setup listeners for task events that need to be bubbled up to TaskManager + * This replaces the previous callback injection pattern + */ + private setupTaskListeners(task: ITask): void { + // Listen for TASK_INCOMING and re-emit so webex.cc can notify consumers + task.on(TASK_EVENTS.TASK_INCOMING, (t: ITask) => { + LoggerProxy.log(`Task incoming event received`, { + module: TASK_MANAGER_FILE, + method: METHODS.REGISTER_TASK_LISTENERS, + interactionId: t.data?.interactionId, + }); + this.emit(TASK_EVENTS.TASK_INCOMING, t); + }); + + // Listen for TASK_HYDRATE on the task and re-emit on TaskManager + task.on(TASK_EVENTS.TASK_HYDRATE, (t: ITask) => { + // Task data is already updated by the task itself before emitting + this.emit(TASK_EVENTS.TASK_HYDRATE, t); + }); } private removeTaskFromCollection(task: ITask) { diff --git a/packages/@webex/contact-center/src/services/task/index.ts b/packages/@webex/contact-center/src/services/task/index.ts index 444f7839bcc..ef7fb8f194d 100644 --- a/packages/@webex/contact-center/src/services/task/index.ts +++ b/packages/@webex/contact-center/src/services/task/index.ts @@ -1,11 +1,10 @@ import EventEmitter from 'events'; import {CALL_EVENT_KEYS, LocalMicrophoneStream} from '@webex/calling'; -import {CallId} from '@webex/calling/dist/types/common/types'; import { generateTaskErrorObject, - deriveConsultTransferDestinationType, - getDestinationAgentId, buildConsultConferenceParamData, + getDestinationAgentId, + deriveConsultTransferDestinationType, } from '../core/Utils'; import {LoginOption} from '../../types'; import {TASK_FILE} from '../../constants'; @@ -24,8 +23,9 @@ import { ConsultPayload, ConsultEndPayload, TransferPayLoad, - DESTINATION_TYPE, ConsultTransferPayLoad, + CallId, + DESTINATION_TYPE, MEDIA_CHANNEL, } from './types'; import WebCallingService from '../WebCallingService'; diff --git a/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts b/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts index 49c79fa8b89..c266b353dfd 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts @@ -55,52 +55,71 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { states: { [TaskState.IDLE]: { on: { + // AgentContactReserved (applicable for direct incoming/consult/transfer/outdial) [TaskEvent.TASK_INCOMING]: { target: TaskState.OFFERED, - actions: ['initializeTask'], + actions: ['initializeTask', 'emitTaskIncoming'], }, + // Some legacy payloads immediately send an OFFER without the reserved event [TaskEvent.OFFER]: { target: TaskState.OFFERED, actions: ['initializeTask'], }, + // AgentContactOffer with enriched payload (WebexCC WebRTC flow) [TaskEvent.OFFER_CONTACT]: { target: TaskState.OFFERED, - actions: ['initializeTask', 'emitTaskOfferContact'], + actions: ['initializeTask', 'emitTaskOfferContact', 'emitTaskIncoming'], }, + // AgentConsultOffer for the receiver side of consults [TaskEvent.OFFER_CONSULT]: { target: TaskState.OFFERED_CONSULT, - actions: ['initializeTask'], + actions: ['initializeTask', 'emitTaskOfferConsult'], + }, + // Consult receivers can get AgentContactAssigned immediately after consult end + [TaskEvent.ASSIGN]: { + target: TaskState.CONNECTED, + actions: ['updateTaskData', 'emitTaskAssigned'], }, }, }, [TaskState.OFFERED]: { on: { + // AgentContactOffer [TaskEvent.TASK_OFFERED]: { - actions: ['updateTaskData', 'emitTaskOfferContact'], + actions: ['updateTaskData', 'emitTaskOfferContact', 'emitTaskIncoming'], }, + // Local intermediate state for ACCEPT button click [TaskEvent.ACCEPT_INITIATED]: { actions: ['setAcceptInitiated'], }, + // Local ACCEPT event that keeps the task in offered state until ASSIGN arrives [TaskEvent.ACCEPT]: { target: TaskState.CONNECTED, }, + // AgentContactAssigned [TaskEvent.ASSIGN]: { target: TaskState.CONNECTED, actions: ['updateTaskData', 'emitTaskAssigned'], }, - [TaskEvent.DECLINE]: { - target: TaskState.TERMINATED, - actions: ['updateTaskData', 'markEnded', 'emitTaskReject'], - }, + // AgentOfferContactRONA [TaskEvent.RONA]: { target: TaskState.TERMINATED, actions: ['updateTaskData', 'markEnded', 'emitTaskReject'], }, + // ContactEnded (customer can end call before connect or via agent softphone decline) [TaskEvent.END]: { target: TaskState.TERMINATED, actions: ['updateTaskData', 'markEnded', 'emitTaskEnd'], }, + // Local intermediate state for DECLINE event -- irrespective of API call, clean up + [TaskEvent.DECLINE]: { + target: TaskState.TERMINATED, + actions: ['updateTaskData', 'markEnded', 'emitTaskReject'], + }, + // This needs to be handled for all assign failed scenarios (contact, buddy) + // [AgentContactAssignFailed, AgentConsultFailed, AgentCtqFailed, AgentBlindTransferFailed, + // AgentVTeamTransferFailed, AgentConsultTransferFailed] [TaskEvent.ASSIGN_FAILED]: { target: TaskState.TERMINATED, actions: ['updateTaskData', 'markEnded', 'emitTaskReject'], @@ -113,27 +132,48 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { target: TaskState.TERMINATED, actions: ['updateTaskData', 'markEnded', 'emitTaskReject'], }, + // AgentConsultOffer, AgentConsulting [TaskEvent.CONSULT_ACCEPTED]: { target: TaskState.CONSULTING, actions: ['updateTaskData', 'handleConsultAccept', 'emitTaskConsultAccepted'], }, + [TaskEvent.OFFER_CONSULT]: { + target: TaskState.OFFERED_CONSULT, + actions: ['updateTaskData', 'emitTaskOfferConsult'], + }, }, }, [TaskState.OFFERED_CONSULT]: { entry: ['emitTaskOfferConsult'], on: { + // Local intermediate state for ACCEPT button click [TaskEvent.ACCEPT_INITIATED]: { actions: ['setAcceptInitiated'], }, + // AgentConsultAccepted from receiver accept button [TaskEvent.ACCEPT]: { target: TaskState.CONSULTING, actions: ['emitTaskConsultAccepted'], }, + // AgentConsultAccepted from backend (consulting agent accepted) [TaskEvent.CONSULT_ACCEPTED]: { target: TaskState.CONSULTING, - actions: ['emitTaskConsultAccepted'], + actions: ['updateTaskData', 'handleConsultAccept', 'emitTaskConsultAccepted'], }, + // AgentConsultingActive tells the consulted agent that the initiator is live + [TaskEvent.CONSULTING_ACTIVE]: [ + { + guard: ({context}: {context: TaskContext}) => !context.consultInitiator, + target: TaskState.CONSULTING, + actions: [ + 'updateTaskData', + 'setConsultAgentJoined', + 'emitTaskConsultAccepted', + 'emitTaskConsulting', + ], + }, + ], [TaskEvent.RONA]: { target: TaskState.TERMINATED, actions: ['updateTaskData', 'markEnded', 'emitTaskReject'], @@ -151,29 +191,32 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { [TaskState.CONNECTED]: { on: { + // Click of hold button [TaskEvent.HOLD_INITIATED]: { target: TaskState.HOLD_INITIATING, actions: ['setHoldInitiated'], }, - [TaskEvent.HOLD]: { - target: TaskState.HOLD_INITIATING, - }, + // Click of the consult button [TaskEvent.CONSULT]: { target: TaskState.CONSULT_INITIATING, actions: ['setConsultInitiator', 'setConsultDestination'], }, + // AgentConsultCreated event confirms the consult request [TaskEvent.CONSULT_CREATED]: { target: TaskState.CONSULTING, actions: ['updateTaskData', 'setConsultInitiator', 'emitTaskConsultCreated'], }, + // AgentConsultAccepted for instant consult scenarios (direct assign of receiver) [TaskEvent.CONSULT_ACCEPTED]: { target: TaskState.CONSULTING, actions: ['updateTaskData', 'handleConsultAccept', 'emitTaskConsultAccepted'], }, + // Click of the transfer button [TaskEvent.TRANSFER]: { - target: TaskState.WRAPPING_UP, + target: TaskState.TRANSFER_INITIATING, actions: ['handleTransferInit'], }, + // Back-end may still send transfer responses even if we did not enter the interim state [TaskEvent.TRANSFER_SUCCESS]: { target: TaskState.WRAPPING_UP, actions: ['updateTaskData', 'markEnded', 'emitTaskEnd', 'finalizeTransfer'], @@ -181,11 +224,12 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { [TaskEvent.TRANSFER_FAILED]: { actions: ['updateTaskData', 'finalizeTransfer'], }, - [TaskEvent.END]: { + // AgentContactEnded Event + [TaskEvent.CONTACT_ENDED]: { target: TaskState.WRAPPING_UP, actions: ['updateTaskData', 'markEnded', 'emitTaskEnd'], }, - [TaskEvent.CONTACT_ENDED]: { + [TaskEvent.END]: { target: TaskState.WRAPPING_UP, actions: ['updateTaskData', 'markEnded', 'emitTaskEnd'], }, @@ -200,30 +244,36 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { [TaskState.HOLD_INITIATING]: { on: { + // AgentContactHeld Event [TaskEvent.HOLD_SUCCESS]: { target: TaskState.HELD, actions: ['updateTaskData', 'setHoldState', 'emitTaskHold'], }, + // AgentContactHoldFailed Event [TaskEvent.HOLD_FAILED]: { target: TaskState.CONNECTED, + actions: ['updateTaskData'], }, }, }, [TaskState.HELD]: { on: { + // Click of the unhold button [TaskEvent.UNHOLD_INITIATED]: { target: TaskState.RESUME_INITIATING, }, [TaskEvent.UNHOLD]: { target: TaskState.RESUME_INITIATING, }, + // Click of the consult button [TaskEvent.CONSULT]: { target: TaskState.CONSULT_INITIATING, actions: ['setConsultInitiator', 'setConsultDestination'], }, + // Click of the transfer button [TaskEvent.TRANSFER]: { - target: TaskState.WRAPPING_UP, + target: TaskState.TRANSFER_INITIATING, actions: ['handleTransferInit'], }, [TaskEvent.TRANSFER_SUCCESS]: { @@ -233,6 +283,10 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { [TaskEvent.TRANSFER_FAILED]: { actions: ['updateTaskData', 'finalizeTransfer'], }, + [TaskEvent.CONTACT_ENDED]: { + target: TaskState.WRAPPING_UP, + actions: ['updateTaskData', 'markEnded', 'emitTaskEnd'], + }, [TaskEvent.END]: { target: TaskState.WRAPPING_UP, actions: ['updateTaskData', 'markEnded', 'emitTaskEnd'], @@ -242,10 +296,12 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { [TaskState.RESUME_INITIATING]: { on: { + // AgentContactUnheld [TaskEvent.UNHOLD_SUCCESS]: { target: TaskState.CONNECTED, actions: ['updateTaskData', 'setHoldState', 'emitTaskResume'], }, + // AgentContactUnHoldFailed [TaskEvent.UNHOLD_FAILED]: { target: TaskState.HELD, }, @@ -254,60 +310,142 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { [TaskState.CONSULT_INITIATING]: { on: { + // AgentContactHeld update while consult is placing the primary leg on hold + [TaskEvent.HOLD_SUCCESS]: { + actions: ['updateTaskData'], + }, + // AgentContactHoldFailed (consult attempt failed to hold main call) + [TaskEvent.HOLD_FAILED]: { + target: TaskState.CONNECTED, + actions: ['updateTaskData', 'handleConsultFailed'], + }, + // AgentConsultCreated + [TaskEvent.CONSULT_CREATED]: { + target: TaskState.CONSULTING, + actions: ['updateTaskData', 'setConsultInitiator', 'emitTaskConsultCreated'], + }, + // AgentConsulting [TaskEvent.CONSULT_SUCCESS]: { target: TaskState.CONSULTING, actions: ['handleConsultCompletion'], }, + // AgentConsultFailed, API Failures, AgentCtqFailed [TaskEvent.CONSULT_FAILED]: { - target: TaskState.CONNECTED, + target: TaskState.HELD, actions: ['updateTaskData', 'handleConsultFailed'], }, + // AgentCtqCancelled Event + [TaskEvent.CTQ_CANCEL]: { + target: TaskState.HELD, + actions: ['clearConsultState'], + }, }, }, [TaskState.CONSULTING]: { on: { + // AgentConsultingActive updates consulted agent arrival [TaskEvent.CONSULTING_ACTIVE]: { actions: ['updateTaskData', 'setConsultAgentJoined', 'emitTaskConsulting'], }, + // AgentConsultEnded + [TaskEvent.CONSULT_END]: [ + { + guard: ({context}: {context: TaskContext}) => Boolean(context.consultInitiator), + target: TaskState.HELD, + actions: ['clearConsultState', 'emitTaskConsultEnd'], + }, + { + target: TaskState.IDLE, + actions: ['clearConsultState', 'emitTaskConsultEnd'], + }, + ], + // Transfer buttons while in consulting + [TaskEvent.TRANSFER]: { + target: TaskState.TRANSFER_INITIATING, + actions: ['handleTransferInit'], + }, + [TaskEvent.CONSULT_TRANSFER]: { + target: TaskState.TRANSFER_INITIATING, + actions: ['handleTransferInit'], + }, + [TaskEvent.TRANSFER_SUCCESS]: { + target: TaskState.WRAPPING_UP, + actions: ['updateTaskData', 'markEnded', 'emitTaskEnd', 'finalizeTransfer'], + }, + [TaskEvent.TRANSFER_FAILED]: { + actions: ['updateTaskData', 'finalizeTransfer'], + }, + // AgentContactAssigned - receiver side becomes connected to customer + [TaskEvent.ASSIGN]: { + target: TaskState.CONNECTED, + actions: ['updateTaskData'], + }, + // AgentContactEnded depending on initiator vs receiver + [TaskEvent.CONTACT_ENDED]: [ + { + guard: ({context}: {context: TaskContext}) => Boolean(context.consultInitiator), + target: TaskState.WRAPPING_UP, + actions: ['updateTaskData', 'markEnded', 'clearConsultState', 'emitTaskEnd'], + }, + { + target: TaskState.TERMINATED, + actions: ['updateTaskData', 'markEnded', 'clearConsultState', 'emitTaskEnd'], + }, + ], + [TaskEvent.END]: { + target: TaskState.WRAPPING_UP, + actions: ['updateTaskData', 'markEnded', 'clearConsultState', 'emitTaskEnd'], + }, + // Local intermediate state for merge to conference button click [TaskEvent.START_CONFERENCE]: { - target: TaskState.CONFERENCING, + target: TaskState.CONF_INITIATING, actions: ['handleConferenceInit'], }, [TaskEvent.MERGE_TO_CONFERENCE]: { - target: TaskState.CONFERENCING, + target: TaskState.CONF_INITIATING, actions: ['handleConferenceInit'], }, + // AgentConsultConferenced, ParticipantJoinedConference [TaskEvent.CONFERENCE_START]: { target: TaskState.CONFERENCING, actions: ['handleConferenceStarted'], }, - [TaskEvent.CONSULT_END]: { - target: TaskState.CONNECTED, - actions: ['clearConsultState', 'emitTaskConsultEnd'], - }, - [TaskEvent.CONSULT_TRANSFER]: { - target: TaskState.WRAPPING_UP, - actions: ['clearConsultState'], - }, - [TaskEvent.TRANSFER]: { - target: TaskState.WRAPPING_UP, - actions: ['handleTransferInit'], + // AgentConsultConferenceFailed + [TaskEvent.CONFERENCE_FAILED]: { + target: TaskState.CONSULTING, + actions: ['handleConferenceFailed'], }, + }, + }, + + [TaskState.TRANSFER_INITIATING]: { + entry: ['clearConsultState'], + on: { + // AgentBlindTransferred, AgentVTeamTransferred, AgentConsultTransferred [TaskEvent.TRANSFER_SUCCESS]: { target: TaskState.WRAPPING_UP, actions: ['updateTaskData', 'markEnded', 'emitTaskEnd', 'finalizeTransfer'], }, + // AgentBlindTransferFailed, AgentVTeamTransferFailed, AgentConsultTransferFailed [TaskEvent.TRANSFER_FAILED]: { - actions: ['updateTaskData', 'finalizeTransfer'], - }, - [TaskEvent.END]: { target: TaskState.WRAPPING_UP, - actions: ['updateTaskData', 'markEnded', 'clearConsultState', 'emitTaskEnd'], + actions: ['updateTaskData', 'markEnded', 'emitTaskEnd', 'finalizeTransfer'], }, - [TaskEvent.CONTACT_ENDED]: { - target: TaskState.WRAPPING_UP, - actions: ['updateTaskData', 'markEnded', 'clearConsultState', 'emitTaskEnd'], + }, + }, + + [TaskState.CONF_INITIATING]: { + on: { + // AgentConsultConferenced, ParticipantJoinedConference + [TaskEvent.CONFERENCE_START]: { + target: TaskState.CONFERENCING, + actions: ['handleConferenceStarted'], + }, + // AgentConsultConferenceFailed + [TaskEvent.CONFERENCE_FAILED]: { + target: TaskState.CONSULTING, + actions: ['handleConferenceFailed'], }, }, }, @@ -318,37 +456,64 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { target: TaskState.CONSULT_INITIATING, actions: ['setConsultInitiator', 'setConsultDestination'], }, - [TaskEvent.EXIT_CONFERENCE]: { - target: TaskState.WRAPPING_UP, - actions: ['updateTaskData', 'markEnded', 'emitTaskEnd'], - }, - [TaskEvent.TRANSFER_CONFERENCE]: { - target: TaskState.WRAPPING_UP, - }, + // ParticpantLeftConference (host leaves ends conference) + [TaskEvent.EXIT_CONFERENCE]: [ + { + guard: ({context}: {context: TaskContext}) => Boolean(context.consultInitiator), + target: TaskState.WRAPPING_UP, + actions: ['updateTaskData', 'markEnded', 'clearConsultState', 'emitTaskEnd'], + }, + { + target: TaskState.CONNECTED, + actions: ['clearConsultState'], + }, + ], + // AgentConferenceTransferred + [TaskEvent.TRANSFER_CONFERENCE]: [ + { + guard: ({context}: {context: TaskContext}) => Boolean(context.consultInitiator), + target: TaskState.WRAPPING_UP, + actions: ['updateTaskData', 'markEnded', 'clearConsultState', 'emitTaskEnd'], + }, + { + target: TaskState.CONNECTED, + actions: ['clearConsultState'], + }, + ], + // AgentConferenceEnded [TaskEvent.CONFERENCE_END]: { - target: TaskState.WRAPPING_UP, - actions: ['updateTaskData', 'markEnded', 'handleConferenceFailed', 'emitTaskEnd'], + target: TaskState.CONNECTED, + actions: ['clearConsultState'], }, + [TaskEvent.CONTACT_ENDED]: [ + { + guard: ({context}: {context: TaskContext}) => Boolean(context.consultInitiator), + target: TaskState.WRAPPING_UP, + actions: ['updateTaskData', 'markEnded', 'clearConsultState', 'emitTaskEnd'], + }, + { + target: TaskState.CONNECTED, + actions: ['clearConsultState'], + }, + ], [TaskEvent.END]: { target: TaskState.WRAPPING_UP, actions: ['updateTaskData', 'markEnded', 'emitTaskEnd'], }, - [TaskEvent.CONTACT_ENDED]: { - target: TaskState.WRAPPING_UP, - actions: ['updateTaskData', 'markEnded', 'handleConferenceFailed', 'emitTaskEnd'], - }, }, }, [TaskState.WRAPPING_UP]: { - entry: ['emitTaskEnd'], + entry: ['emitTaskEnd', 'emitTaskWrapup'], on: { + // AgentWrapup Event [TaskEvent.WRAPUP]: { target: TaskState.COMPLETED, }, [TaskEvent.AUTO_WRAPUP]: { target: TaskState.COMPLETED, }, + // AgentWrappedup Event [TaskEvent.WRAPUP_COMPLETE]: { target: TaskState.COMPLETED, actions: ['updateTaskData'], diff --git a/packages/@webex/contact-center/src/services/task/state-machine/actions.ts b/packages/@webex/contact-center/src/services/task/state-machine/actions.ts index d5c5f20f1a5..8ca84b75760 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/actions.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/actions.ts @@ -33,6 +33,22 @@ type RecordingStateUpdate = Partial< Pick >; +const determineConsultInitiator = (taskData?: TaskData): boolean | undefined => { + const participants = taskData?.interaction?.participants; + const destAgentId = taskData?.destAgentId; + + if (!participants || !destAgentId) { + return undefined; + } + + const participant = participants[destAgentId]; + if (!participant || participant.isConsulted === undefined) { + return undefined; + } + + return !participant.isConsulted; +}; + const deriveRecordingState = (taskData?: TaskData | null): RecordingStateUpdate => { const callProcessingDetails = taskData?.interaction?.callProcessingDetails; @@ -89,10 +105,19 @@ const deriveRecordingState = (taskData?: TaskData | null): RecordingStateUpdate */ const deriveTaskDataUpdates = (_context: TaskContext, taskData: TaskData | undefined) => taskData - ? { - taskData, - ...deriveRecordingState(taskData), - } + ? (() => { + const updates: Partial = { + taskData, + ...deriveRecordingState(taskData), + }; + + const consultInitiator = determineConsultInitiator(taskData); + if (consultInitiator !== undefined) { + updates.consultInitiator = consultInitiator; + } + + return updates; + })() : {}; const getTaskDataFromEvent = (event?: TaskEventPayload): TaskData | undefined => @@ -369,6 +394,7 @@ export const actions: TaskActionsMap = { * Placeholder emitters that get overridden by consumers when needed * These are invoked by the state machine to trigger task events */ + emitTaskIncoming: () => undefined, emitTaskHydrate: () => undefined, emitTaskOfferContact: () => undefined, emitTaskAssigned: () => undefined, @@ -383,6 +409,7 @@ export const actions: TaskActionsMap = { emitTaskConsultQueueCancelled: () => undefined, emitTaskConsultQueueFailed: () => undefined, emitTaskReject: () => undefined, + emitTaskWrapup: () => undefined, emitTaskRecordingStarted: () => undefined, emitTaskRecordingPaused: () => undefined, emitTaskRecordingPauseFailed: () => undefined, diff --git a/packages/@webex/contact-center/src/services/task/state-machine/constants.ts b/packages/@webex/contact-center/src/services/task/state-machine/constants.ts index 661fa652d0b..4a6bfcf314e 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/constants.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/constants.ts @@ -15,6 +15,8 @@ export enum TaskState { RESUME_INITIATING = 'RESUME_INITIATING', CONSULT_INITIATING = 'CONSULT_INITIATING', CONSULTING = 'CONSULTING', + TRANSFER_INITIATING = 'TRANSFER_INITIATING', + CONF_INITIATING = 'CONF_INITIATING', CONFERENCING = 'CONFERENCING', WRAPPING_UP = 'WRAPPING_UP', @@ -72,6 +74,7 @@ export enum TaskEvent { START_CONFERENCE = 'START_CONFERENCE', MERGE_TO_CONFERENCE = 'MERGE_TO_CONFERENCE', CONFERENCE_START = 'CONFERENCE_START', + CONFERENCE_FAILED = 'CONFERENCE_FAILED', CONFERENCE_END = 'CONFERENCE_END', TRANSFER_CONFERENCE = 'TRANSFER_CONFERENCE', PARTICIPANT_JOIN = 'PARTICIPANT_JOIN', @@ -120,6 +123,7 @@ export enum TaskAction { EMIT_TASK_CONSULTING = 'emitTaskConsulting', EMIT_TASK_CONSULT_END = 'emitTaskConsultEnd', EMIT_TASK_END = 'emitTaskEnd', + EMIT_TASK_WRAPUP = 'emitTaskWrapup', EMIT_TASK_WRAPPEDUP = 'emitTaskWrappedup', CLEANUP_RESOURCES = 'cleanupResources', diff --git a/packages/@webex/contact-center/src/services/task/state-machine/types.ts b/packages/@webex/contact-center/src/services/task/state-machine/types.ts index 570206426ed..02287eb2662 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/types.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/types.ts @@ -150,6 +150,9 @@ interface TaskEventPayloadMap { [TaskEvent.CONFERENCE_START]: BaseEvent & { participants?: ConferenceParticipant[]; }; + [TaskEvent.CONFERENCE_FAILED]: BaseEvent & { + reason?: string; + }; [TaskEvent.CONFERENCE_END]: BaseEvent; [TaskEvent.TRANSFER_CONFERENCE]: BaseEvent & {agentId?: string}; [TaskEvent.PARTICIPANT_JOIN]: BaseEvent & { diff --git a/packages/@webex/contact-center/src/services/task/state-machine/uiControlsComputer.ts b/packages/@webex/contact-center/src/services/task/state-machine/uiControlsComputer.ts index b194d83748a..1c84739d869 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/uiControlsComputer.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/uiControlsComputer.ts @@ -60,9 +60,11 @@ function computeVoiceUIControls( currentState === TaskState.OFFERED || currentState === TaskState.OFFERED_CONSULT; const isConnected = currentState === TaskState.CONNECTED; const isHeld = currentState === TaskState.HELD; - const isConsulting = currentState === TaskState.CONSULTING; + const isTransferInitiating = currentState === TaskState.TRANSFER_INITIATING; + const isConfInitiating = currentState === TaskState.CONF_INITIATING; + const isConsulting = currentState === TaskState.CONSULTING || isConfInitiating; const isConferencing = currentState === TaskState.CONFERENCING; - const isWrappingUp = currentState === TaskState.WRAPPING_UP; + const isWrappingUp = currentState === TaskState.WRAPPING_UP || isTransferInitiating; const taskData = context.taskData ?? fallbackTaskData ?? null; const isConsultedAgent = Boolean(taskData?.isConsulted); const isTerminated = taskData?.interaction?.isTerminated ?? false; @@ -184,7 +186,8 @@ function computeDigitalUIControls( ): TaskUIControls { const isOffered = currentState === TaskState.OFFERED; const isConnected = currentState === TaskState.CONNECTED; - const isWrappingUp = currentState === TaskState.WRAPPING_UP; + const isWrappingUp = + currentState === TaskState.WRAPPING_UP || currentState === TaskState.TRANSFER_INITIATING; const taskData = context.taskData ?? fallbackTaskData ?? null; const isTerminated = taskData?.interaction?.isTerminated ?? false; diff --git a/packages/@webex/contact-center/src/services/task/types.ts b/packages/@webex/contact-center/src/services/task/types.ts index bf142bf722c..dd706579de1 100644 --- a/packages/@webex/contact-center/src/services/task/types.ts +++ b/packages/@webex/contact-center/src/services/task/types.ts @@ -1,6 +1,6 @@ /* eslint-disable import/no-cycle */ // eslint-disable-next-line import/no-unresolved -import {CallId} from '@webex/calling/dist/types/common/types'; +import type {CallId as WebexCallId} from '@webex/calling/dist/types/common/types'; import EventEmitter from 'events'; import type {AnyActorRef} from 'xstate'; import {Msg} from '../core/GlobalTypes'; @@ -1251,6 +1251,15 @@ export type TransferPayload = { destinationType: DestinationType; }; +/** + * Options for configuring transfer behavior + * @public + */ +export type TransferOptions = { + /** Additional transfer configuration options */ + [key: string]: unknown; +}; + /** * API payload for ending a consultation * This is the actual payload that is sent to the developer API @@ -1599,18 +1608,7 @@ export interface ITask extends EventEmitter { * await task.transfer({ to: "queueId", destinationType: "queue" }); * ``` */ - transfer(transferPayload: TransferPayLoad): Promise; - - /** - * Transfers the task after consultation. - * @param consultTransferPayload - Details for consult transfer (optional) - * @returns Promise - * @example - * ```typescript - * await task.consultTransfer({ to: "agentId", destinationType: "agent" }); - * ``` - */ - consultTransfer(consultTransferPayload?: ConsultTransferPayLoad): Promise; + transfer(transferPayload: TransferPayLoad, options?: TransferOptions): Promise; /** * Initiates a consult conference (merge consult call with main call). @@ -1731,3 +1729,4 @@ export type IOldTask = ITask; */ // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface IWebRTC {} +export type CallId = WebexCallId; diff --git a/packages/@webex/contact-center/src/services/task/voice/Voice.ts b/packages/@webex/contact-center/src/services/task/voice/Voice.ts index d7092c37896..5c5715c45e4 100644 --- a/packages/@webex/contact-center/src/services/task/voice/Voice.ts +++ b/packages/@webex/contact-center/src/services/task/voice/Voice.ts @@ -1,5 +1,5 @@ import {CC_FILE, METHODS} from '../../../constants'; -import {getErrorDetails} from '../../core/Utils'; +import {buildConsultConferenceParamData, getErrorDetails} from '../../core/Utils'; import routingContact from '../contact'; import { ConsultPayload, @@ -11,6 +11,7 @@ import { VoiceUIControlOptions, TransferPayLoad, ConsultTransferPayLoad, + consultConferencePayloadData, CONSULT_TRANSFER_DESTINATION_TYPE, TASK_CHANNEL_TYPE, TASK_EVENTS, @@ -635,6 +636,83 @@ export default class Voice extends Task implements IVoice { } } + /** + * Start a consult conference, merging main and consult calls. + */ + public async consultConference(): Promise { + const consultationData: consultConferencePayloadData = { + agentId: this.data.agentId, + destinationType: this.data.destinationType || 'agent', + destAgentId: this.data.destAgentId, + }; + + try { + LoggerProxy.info(`Initiating consult conference to ${consultationData.destAgentId}`, { + module: CC_FILE, + method: METHODS.CONSULT_CONFERENCE, + interactionId: this.data.interactionId, + }); + + const paramsDataForConferenceV2 = buildConsultConferenceParamData( + consultationData, + this.data.interactionId + ); + + const response = await this.contact.consultConference({ + interactionId: paramsDataForConferenceV2.interactionId, + data: paramsDataForConferenceV2.data, + }); + + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.TASK_CONFERENCE_START_SUCCESS, + { + taskId: this.data.interactionId, + destination: paramsDataForConferenceV2.data.to, + destinationType: paramsDataForConferenceV2.data.destinationType, + agentId: paramsDataForConferenceV2.data.agentId, + ...MetricsManager.getCommonTrackingFieldForAQMResponse(response), + }, + ['operational', 'behavioral', 'business'] + ); + + LoggerProxy.log(`Consult conference started successfully`, { + module: CC_FILE, + method: METHODS.CONSULT_CONFERENCE, + interactionId: this.data.interactionId, + }); + + return response; + } catch (error) { + const {error: detailedError} = getErrorDetails(error, METHODS.CONSULT_CONFERENCE, CC_FILE); + + const failedParamsData = buildConsultConferenceParamData( + consultationData, + this.data.interactionId + ); + + this.metricsManager.trackEvent( + METRIC_EVENT_NAMES.TASK_CONFERENCE_START_FAILED, + { + taskId: this.data.interactionId, + destination: failedParamsData.data.to, + destinationType: failedParamsData.data.destinationType, + agentId: failedParamsData.data.agentId, + error: error.toString(), + ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed(error.details || {}), + }, + ['operational', 'behavioral', 'business'] + ); + + LoggerProxy.error(`Failed to start consult conference`, { + module: CC_FILE, + method: METHODS.CONSULT_CONFERENCE, + interactionId: this.data.interactionId, + }); + + throw detailedError; + } + } + protected override getChannelSpecificActionOverrides() { const baseOverrides = super.getChannelSpecificActionOverrides(); diff --git a/packages/@webex/contact-center/test/unit/spec/services/task/Task.ts b/packages/@webex/contact-center/test/unit/spec/services/task/Task.ts index ff93a906a78..b16eecac944 100644 --- a/packages/@webex/contact-center/test/unit/spec/services/task/Task.ts +++ b/packages/@webex/contact-center/test/unit/spec/services/task/Task.ts @@ -1,5 +1,5 @@ import Task from '../../../../../src/services/task/Task'; -import {TaskData, DESTINATION_TYPE} from '../../../../../src/services/task/types'; +import {TaskData, DESTINATION_TYPE, TASK_EVENTS} from '../../../../../src/services/task/types'; import {TaskEvent} from '../../../../../src/services/task/state-machine'; import LoggerProxy from '../../../../../src/logger-proxy'; import {createTaskData} from './taskTestUtils'; @@ -123,6 +123,26 @@ describe('Task (base class)', () => { transitionTask.stateMachineService?.stop(); }); + it('emits task:wrapup when wrap-up is required', () => { + const overrides = (task as any).getStateMachineActionOverrides(); + const emitSpy = jest.spyOn(task, 'emit'); + + task.updateTaskData(createTaskData({wrapUpRequired: true}) as TaskData); + overrides.emitTaskWrapup({event: {type: TaskEvent.END}}); + + expect(emitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_WRAPUP, task); + }); + + it('does not emit task:wrapup when wrap-up is not required', () => { + const overrides = (task as any).getStateMachineActionOverrides(); + const emitSpy = jest.spyOn(task, 'emit'); + + task.updateTaskData(createTaskData({wrapUpRequired: false}) as TaskData); + overrides.emitTaskWrapup({event: {type: TaskEvent.END}}); + + expect(emitSpy).not.toHaveBeenCalledWith(TASK_EVENTS.TASK_WRAPUP, task); + }); + }); describe('Task common methods', () => {