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/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 dd702346640..86710fafcce 100644 --- a/packages/@webex/contact-center/package.json +++ b/packages/@webex/contact-center/package.json @@ -53,7 +53,9 @@ "@webex/plugin-logger": "workspace:*", "@webex/webex-core": "workspace:*", "jest-html-reporters": "3.0.11", - "lodash": "^4.17.21" + "lodash": "^4.17.21", + "uuid": "^3.3.2", + "xstate": "5.24.0" }, "devDependencies": { "@babel/core": "^7.22.11", @@ -66,6 +68,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", @@ -78,6 +81,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/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/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/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 b6b07c66a1c..97ba6ff5e22 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 @@ -275,6 +286,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 +351,11 @@ export type DesktopProfileResponse = { */ autoWrapUp: boolean; + /** + * Whether the agent personal greeting is enabled. + */ + agentPersonalGreeting: boolean; + /** * Auto answer allowed. */ @@ -335,6 +376,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 +449,11 @@ export type DesktopProfileResponse = { */ agentDNValidation: string; + /** + * Additional DN validation criteria configured for the agent. + */ + agentDNValidationCriterions: string[]; + /** * Dial plans of the agent. */ @@ -412,6 +488,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; }; /** @@ -856,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 @@ -984,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 4a783f4c7fc..62ad0b651c7 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 {createActor} from 'xstate'; +import type {ActorRefFrom, SnapshotFrom} from 'xstate'; import { ITask, TaskData, @@ -7,9 +8,9 @@ import { WrapupPayLoad, TaskId, TransferPayLoad, - TaskButtonControl, - TaskUIControls, DESTINATION_TYPE, + TASK_EVENTS, + TaskUIControls, } from './types'; import {CC_FILE} from '../../constants'; import {getErrorDetails} from '../core/Utils'; @@ -17,21 +18,347 @@ import routingContact from './contact'; import MetricsManager from '../../metrics/MetricsManager'; import {METRIC_EVENT_NAMES} from '../../metrics/constants'; import LoggerProxy from '../../logger-proxy'; +import {createTaskStateMachine, TaskState} from './state-machine'; +import type { + TaskEventPayload, + TaskStateMachine, + UIControlConfig, + TaskContext, +} from './state-machine'; +import AutoWrapup from './AutoWrapup'; +import { + computeUIControls, + 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; + public stateMachineService?: ActorRefFrom; public data: TaskData; public webCallMap: Record; - public taskUiControls: TaskUIControls; + public state?: SnapshotFrom; + private lastState?: TaskState; + protected currentUiControls: TaskUIControls; + protected uiControlConfig: UIControlConfig; + protected actionCallbacks?: TaskActionCallbacks; - constructor(contact: ReturnType, data: TaskData) { + constructor( + contact: ReturnType, + data: TaskData, + 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.initialiseUIControls(); + this.currentUiControls = getDefaultUIControls(); + this.initializeStateMachine(); + } + + // Properties from ITask interface + public autoWrapup?: AutoWrapup; + + // 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'); + } + + public async pauseRecording(): Promise { + this.unsupportedMethodError('pauseRecording'); + } + + public async resumeRecording(): Promise { + this.unsupportedMethodError('resumeRecording'); + } + + public async consult(): Promise { + this.unsupportedMethodError('consult'); + } + + public async endConsult(): Promise { + this.unsupportedMethodError('endConsult'); + } + + public async consultTransfer(): Promise { + this.unsupportedMethodError('consultTransfer'); + } + + public async consultConference(): Promise { + this.unsupportedMethodError('consultConference'); + } + + public async exitConference(): Promise { + this.unsupportedMethodError('exitConference'); + } + + public async transferConference(): Promise { + this.unsupportedMethodError('transferConference'); + } + + public async toggleMute(): Promise { + this.unsupportedMethodError('toggleMute'); + } + + public unregisterWebCallListeners(): void { + // Default implementation - child classes can override + LoggerProxy.log('unregisterWebCallListeners called', { + module: CC_FILE, + method: 'unregisterWebCallListeners', + interactionId: this.data?.interactionId, + }); + } + + /** + * 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'); + } + + public async resume(): Promise { + this.unsupportedMethodError('resume'); + } + + public async holdResume(): Promise { + this.unsupportedMethodError('holdResume'); + } + + /** + * Latest UI controls derived from state machine state and context. + */ + 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; + + if (shouldEmit) { + this.emit(TASK_EVENTS.TASK_UI_CONTROLS_UPDATED, this.currentUiControls); + } + } + + /** + * Initialize the state machine + */ + private initializeStateMachine(): void { + const machine: TaskStateMachine = createTaskStateMachine(this.uiControlConfig, { + actions: this.getStateMachineActionOverrides(), + }); + + 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); + } + + /** + * 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?.getSnapshot()?.value as TaskState; + } + + /** + * Compute UI controls based on current state machine state. + * + * @returns UI control states for all task actions + */ + protected computeUIControls(): TaskUIControls { + 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); + } + + /** + * Stop the state machine service + */ + protected stopStateMachine(): void { + if (this.stateMachineService) { + this.stateMachineService.stop(); + this.stateMachineService = undefined; + } + } + + 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 { @@ -46,28 +373,6 @@ 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. - */ - protected setUIControls() {} - /** * * @param methodName - The name of the method that is unsupported @@ -77,26 +382,11 @@ 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}`); } - /** - * Apply visibility & enabled flags in one go. - * Usage: updateTaskUiControls({ hold: [true,true], end: [false,true] }) - */ - protected updateTaskUiControls( - config: Partial> - ): void { - Object.entries(config).forEach(([k, [vis, en]]) => { - const ctl = this.taskUiControls[k as keyof typeof this.taskUiControls]; - if (ctl) { - ctl.setVisiblity(vis); - ctl.setEnabled(en); - } - }); - } - /** * This method is used to update the task data. * @param updatedData - TaskData @@ -107,12 +397,13 @@ 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); - this.setUIControls(); - } - public abstract accept(): Promise; + this.updateUiControls(); + + return this; + } /** * This is used to blind transfer or vTeam transfer the task diff --git a/packages/@webex/contact-center/src/services/task/TaskFactory.ts b/packages/@webex/contact-center/src/services/task/TaskFactory.ts index 0594a337d85..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,26 +15,30 @@ 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 {isEndCallEnabled, isEndConsultEnabled} = configFlags; + const {isEndTaskEnabled, isEndConsultEnabled} = configFlags; + const recordingEnabled = data?.interaction?.callProcessingDetails?.pauseResumeEnabled ?? true; + const voiceControlOptions = { + isEndTaskEnabled, + 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, runtimeOptions); } - return new Voice(contact, data, { - isEndCallEnabled, - isEndConsultEnabled, - }); + 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 e1d72789199..9dd3e26ac81 100644 --- a/packages/@webex/contact-center/src/services/task/TaskManager.ts +++ b/packages/@webex/contact-center/src/services/task/TaskManager.ts @@ -12,13 +12,59 @@ 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'; +import {normalizeTaskData} from './taskDataNormalizer'; +import type {TaskActionCallbacks, TaskRuntimeOptions} from './Task'; + +type WebSocketPayload = TaskData & { + type: CC_EVENTS | string; + mediaResourceId?: string; + reason?: string; +}; + +type WebSocketMessage = { + keepalive?: 'true' | 'false' | boolean; + 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); /** @internal */ export default class TaskManager extends EventEmitter { private call: ICall; @@ -36,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 @@ -52,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(); } @@ -105,316 +153,561 @@ 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 static mapEventToTaskStateMachineEvent( + ccEvent: CC_EVENTS, + payload: WebSocketPayload + ): TaskEventPayload | null { + const mediaResourceId = + payload.mediaResourceId || + payload.interaction?.media?.[payload.interactionId]?.mediaResourceId; + + switch (ccEvent) { + case CC_EVENTS.AGENT_CONTACT_RESERVED: + return {type: TaskEvent.TASK_INCOMING, taskData: payload}; + + case CC_EVENTS.AGENT_OFFER_CONTACT: + 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, isConsulted: true}, + }; + + case CC_EVENTS.AGENT_CONTACT_ASSIGNED: + return {type: TaskEvent.ASSIGN, taskData: payload}; + + case CC_EVENTS.AGENT_CONTACT_HELD: + return { + type: TaskEvent.HOLD_SUCCESS, + mediaResourceId: mediaResourceId || '', + taskData: payload, + }; + + case CC_EVENTS.AGENT_CONTACT_UNHELD: + return { + type: TaskEvent.UNHOLD_SUCCESS, + mediaResourceId: mediaResourceId || '', + taskData: payload, + }; + + case CC_EVENTS.AGENT_CONSULT_CREATED: + 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, taskData: payload}; + + case CC_EVENTS.AGENT_CONSULT_FAILED: + return {type: TaskEvent.CONSULT_FAILED, reason: payload.reason, taskData: payload}; + + case CC_EVENTS.AGENT_CTQ_CANCELLED: + 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.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, + taskData: { + ...payload, + wrapUpRequired: payload.interaction?.state !== 'new', + }, + }; + + case CC_EVENTS.AGENT_INVITE_FAILED: + return {type: TaskEvent.INVITE_FAILED, taskData: payload}; + + case CC_EVENTS.AGENT_CONTACT_ASSIGN_FAILED: + return {type: TaskEvent.ASSIGN_FAILED, taskData: payload}; + + case CC_EVENTS.AGENT_CONTACT_OFFER_RONA: + return {type: TaskEvent.RONA, taskData: payload}; + + case CC_EVENTS.AGENT_OUTBOUND_FAILED: + return {type: TaskEvent.OUTBOUND_FAILED, taskData: payload}; + + case CC_EVENTS.CONTACT_RECORDING_STARTED: + return {type: TaskEvent.RECORDING_STARTED, taskData: payload}; + + case CC_EVENTS.CONTACT_RECORDING_PAUSED: + return {type: TaskEvent.PAUSE_RECORDING, taskData: payload}; + + case CC_EVENTS.CONTACT_RECORDING_RESUMED: + 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 + 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: WebSocketPayload, + task?: ITask, + stateMachineEvent?: TaskEventPayload | null + ): void { + // Check if task has state machine + const taskWithStateMachine = task as any; + if (!taskWithStateMachine?.sendStateMachineEvent) { + return; + } + + const eventPayload = + stateMachineEvent ?? TaskManager.mapEventToTaskStateMachineEvent(ccEvent, payload); + + 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 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) => { - const payload = JSON.parse(event); - // 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}`, { - module: TASK_MANAGER_FILE, - method: METHODS.REGISTER_TASK_LISTENERS, - interactionId: payload.data?.interactionId, - }); - switch (payload.data.type) { - 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) - 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: - 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_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.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); - 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; - } - if (task) { - task.emit(payload.data.type, payload.data); - } + // 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 null; } + + // Normalize task data if present + if (payload?.data?.interaction) { + payload.data = normalizeTaskData(payload.data); + } + + return payload; + } catch (error) { + LoggerProxy.error('Failed to parse WebSocket message', { + module: TASK_MANAGER_FILE, + method: 'parseWebSocketMessage', + error, + }); + + return null; + } + } + + /** + * 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; + + 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 { @@ -445,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/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..e44e92275cb 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,38 @@ 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 {IDigital, TaskResponse, TaskData, TASK_CHANNEL_TYPE} from '../types'; +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); - this.updateTaskUiControls({accept: [true, true]}); + constructor( + contact: ReturnType, + data: TaskData, + runtimeOptions: TaskRuntimeOptions = {} + ) { + super( + contact, + data, + { + channelType: TASK_CHANNEL_TYPE.DIGITAL, + isEndTaskEnabled: true, + isEndConsultEnabled: false, + isRecordingEnabled: false, + }, + runtimeOptions + ); + } + + /** + * Refresh the digital task with the latest backend payload and recompute UI controls. + */ + public updateTaskData(newData: TaskData, shouldOverwrite = false): IDigital { + super.updateTaskData(newData, shouldOverwrite); + + return this; } /** @@ -69,63 +90,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/TaskStateMachine.ts b/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts new file mode 100644 index 00000000000..49c79fa8b89 --- /dev/null +++ b/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts @@ -0,0 +1,392 @@ +/** + * 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 {setup} from 'xstate'; +import {TaskContext, TaskEventPayload, UIControlConfig} from './types'; +import {TaskState, TaskEvent} from './constants'; +import {actions, createInitialContext, TaskActionsMap} 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 + * + * @param uiControlConfig - UI control configuration + * @returns State machine configuration object + */ +export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { + return { + id: 'taskStateMachine', + initial: TaskState.IDLE, + context: createInitialContext(uiControlConfig, TaskState.IDLE), + on: { + [TaskEvent.RECORDING_STARTED]: { + 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'], + }, + }, + }, + + [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', 'emitTaskAssigned'], + }, + [TaskEvent.DECLINE]: { + target: TaskState.TERMINATED, + actions: ['updateTaskData', 'markEnded', 'emitTaskReject'], + }, + [TaskEvent.RONA]: { + target: TaskState.TERMINATED, + actions: ['updateTaskData', 'markEnded', 'emitTaskReject'], + }, + [TaskEvent.END]: { + target: TaskState.TERMINATED, + 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: ['updateTaskData', 'markEnded', 'emitTaskReject'], + }, + [TaskEvent.END]: { + target: TaskState.TERMINATED, + 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, + }, + [TaskEvent.CONSULT]: { + target: TaskState.CONSULT_INITIATING, + actions: ['setConsultInitiator', 'setConsultDestination'], + }, + [TaskEvent.CONSULT_CREATED]: { + target: TaskState.CONSULTING, + 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: ['updateTaskData', 'markEnded', 'emitTaskEnd'], + }, + [TaskEvent.CONTACT_ENDED]: { + target: TaskState.WRAPPING_UP, + actions: ['updateTaskData', 'markEnded', 'emitTaskEnd'], + }, + [TaskEvent.PAUSE_RECORDING]: { + actions: ['updateTaskData', 'setRecordingState', 'emitTaskRecordingPaused'], + }, + [TaskEvent.RESUME_RECORDING]: { + actions: ['updateTaskData', 'setRecordingState', 'emitTaskRecordingResumed'], + }, + }, + }, + + [TaskState.HOLD_INITIATING]: { + on: { + [TaskEvent.HOLD_SUCCESS]: { + target: TaskState.HELD, + actions: ['updateTaskData', 'setHoldState', 'emitTaskHold'], + }, + [TaskEvent.HOLD_FAILED]: { + target: TaskState.CONNECTED, + }, + }, + }, + + [TaskState.HELD]: { + on: { + [TaskEvent.UNHOLD_INITIATED]: { + target: TaskState.RESUME_INITIATING, + }, + [TaskEvent.UNHOLD]: { + target: TaskState.RESUME_INITIATING, + }, + [TaskEvent.CONSULT]: { + target: TaskState.CONSULT_INITIATING, + actions: ['setConsultInitiator', 'setConsultDestination'], + }, + [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: ['updateTaskData', 'markEnded', 'emitTaskEnd'], + }, + }, + }, + + [TaskState.RESUME_INITIATING]: { + on: { + [TaskEvent.UNHOLD_SUCCESS]: { + target: TaskState.CONNECTED, + actions: ['updateTaskData', 'setHoldState', 'emitTaskResume'], + }, + [TaskEvent.UNHOLD_FAILED]: { + target: TaskState.HELD, + }, + }, + }, + + [TaskState.CONSULT_INITIATING]: { + on: { + [TaskEvent.CONSULT_SUCCESS]: { + target: TaskState.CONSULTING, + actions: ['handleConsultCompletion'], + }, + [TaskEvent.CONSULT_FAILED]: { + target: TaskState.CONNECTED, + actions: ['updateTaskData', 'handleConsultFailed'], + }, + }, + }, + + [TaskState.CONSULTING]: { + on: { + [TaskEvent.CONSULTING_ACTIVE]: { + 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', 'emitTaskConsultEnd'], + }, + [TaskEvent.CONSULT_TRANSFER]: { + target: TaskState.WRAPPING_UP, + actions: ['clearConsultState'], + }, + [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: ['updateTaskData', 'markEnded', 'clearConsultState', 'emitTaskEnd'], + }, + [TaskEvent.CONTACT_ENDED]: { + target: TaskState.WRAPPING_UP, + 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: ['updateTaskData', 'markEnded', 'emitTaskEnd'], + }, + [TaskEvent.TRANSFER_CONFERENCE]: { + target: TaskState.WRAPPING_UP, + }, + [TaskEvent.CONFERENCE_END]: { + target: TaskState.WRAPPING_UP, + actions: ['updateTaskData', 'markEnded', 'handleConferenceFailed', 'emitTaskEnd'], + }, + [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'], + on: { + [TaskEvent.WRAPUP]: { + target: TaskState.COMPLETED, + }, + [TaskEvent.AUTO_WRAPUP]: { + target: TaskState.COMPLETED, + }, + [TaskEvent.WRAPUP_COMPLETE]: { + target: TaskState.COMPLETED, + actions: ['updateTaskData'], + }, + }, + }, + + [TaskState.COMPLETED]: { + type: 'final' as const, + entry: ['cleanupResources', 'emitTaskWrappedup'], + }, + + [TaskState.TERMINATED]: { + type: 'final' as const, + entry: ['cleanupResources'], + }, + }, + }; +} + +/** + * 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 + */ +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 new file mode 100644 index 00000000000..d5c5f20f1a5 --- /dev/null +++ b/packages/@webex/contact-center/src/services/task/state-machine/actions.ts @@ -0,0 +1,421 @@ +/** + * 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. + * + * 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, UIControlConfig} from './types'; +import {TaskEvent, TaskState} from './constants'; +import {TaskData} from '../types'; +import {computeUIControls, getDefaultUIControls} from './uiControlsComputer'; + +export type TaskActionsMap = ActionFunctionMap< + TaskContext, + TaskEventPayload, + never, + {type: string; params: undefined}, + never, + never, + EventObject +>; + +type RecordingStateUpdate = Partial< + Pick +>; + +const deriveRecordingState = (taskData?: TaskData | null): RecordingStateUpdate => { + const callProcessingDetails = taskData?.interaction?.callProcessingDetails; + + if (!callProcessingDetails) { + return {}; + } + + const update: RecordingStateUpdate = {}; + const {recordingStarted, recordInProgress, isPaused} = callProcessingDetails as { + recordingStarted?: boolean; + recordInProgress?: boolean; + isPaused?: boolean; + }; + + // Recording availability toggles when backend explicitly tells if the feature is on + 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; + } + + if (isPaused !== undefined) { + update.recordingControlsAvailable = true; + update.recordingInProgress = !isPaused; + } + + 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 | 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. + * + * 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 + * @returns Initial context with UI controls + */ +export function createInitialContext( + uiControlConfig: UIControlConfig, + initialState: TaskState = TaskState.IDLE +): TaskContext { + const baseContext: TaskContext = { + taskData: null, + acceptInitiated: false, + holdInitiated: false, + transferInitiated: false, + conferenceInitiated: false, + consultInitiator: false, + consultDestination: null, + consultDestinationAgentJoined: false, + recordingControlsAvailable: false, + recordingInProgress: 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}: {context: TaskContext}) => ({ + uiControls: computeUIControls(currentState, context), + })); +} + +/** + * Action implementations + * These return XState assign actions that update the context + */ +export const actions: TaskActionsMap = { + /** + * Initialize task with offer data + */ + initializeTask: assign(({context, event}: {context: TaskContext; event: TaskEventPayload}) => { + return { + acceptInitiated: false, + holdInitiated: false, + transferInitiated: false, + conferenceInitiated: false, + ...deriveTaskDataUpdates(context, getTaskDataFromEvent(event)), + }; + }), + + /** + * Update task data from ASSIGN event + */ + updateTaskData: assign(({context, event}: {context: TaskContext; event: TaskEventPayload}) => { + return deriveTaskDataUpdates(context, getTaskDataFromEvent(event)); + }), + + /** + * Set consult initiator flag + */ + setConsultInitiator: assign({ + 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 + */ + setConsultDestination: assign(({event}: {event: TaskEventPayload}) => { + if (!event || event.type !== TaskEvent.CONSULT || !('destination' in event)) { + return {}; + } + + return { + consultDestination: (event as {destination: string}).destination, + }; + }), + + /** + * Mark that consult destination agent has joined + */ + setConsultAgentJoined: assign(({event}: {event: TaskEventPayload}) => { + if ( + !event || + event.type !== TaskEvent.CONSULTING_ACTIVE || + !('consultDestinationAgentJoined' in event) + ) { + return {}; + } + + return { + consultDestinationAgentJoined: (event as {consultDestinationAgentJoined: boolean}) + .consultDestinationAgentJoined, + }; + }), + + /** + * Set recording state + */ + setRecordingState: assign(({event}: {event: TaskEventPayload}) => { + if (!event || !('type' in event)) { + return {}; + } + + if (event.type === TaskEvent.PAUSE_RECORDING) { + return { + recordingControlsAvailable: true, + recordingInProgress: false, + }; + } + if (event.type === TaskEvent.RESUME_RECORDING) { + return { + recordingControlsAvailable: true, + recordingInProgress: true, + }; + } + + return {}; + }), + + /** + * Clear consult state + */ + clearConsultState: assign({ + consultDestination: null, + consultDestinationAgentJoined: false, + conferenceInitiated: false, + }), + + /** + * Track hold state updates (currently no-op placeholder) + */ + 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]; + + if (!interaction || !mediaEntry) { + return {}; + } + + const updatedMedia = { + ...interaction.media, + [mediaResourceId]: { + ...mediaEntry, + isHold: event.type === TaskEvent.HOLD_SUCCESS, + }, + }; + + return { + taskData: { + ...(context.taskData as TaskData), + interaction: { + ...interaction, + media: updatedMedia, + }, + }, + holdInitiated: false, + }; + }), + + /** + * Mark task as ended (currently no-op placeholder) + */ + markEnded: assign(() => ({ + recordingControlsAvailable: false, + recordingInProgress: false, + })), + + /** + * Cleanup resources on task completion (placeholder) + */ + 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, +}; + +/** + * 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. + */ 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..661fa652d0b --- /dev/null +++ b/packages/@webex/contact-center/src/services/task/state-machine/constants.ts @@ -0,0 +1,134 @@ +/** + * 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 { + 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', + + // Hold/Resume events + HOLD = 'HOLD', + HOLD_SUCCESS = 'HOLD_SUCCESS', + HOLD_FAILED = 'HOLD_FAILED', + UNHOLD = 'UNHOLD', + UNHOLD_SUCCESS = 'UNHOLD_SUCCESS', + UNHOLD_FAILED = 'UNHOLD_FAILED', + HOLD_INITIATED = 'HOLD_INITIATED', + UNHOLD_INITIATED = 'UNHOLD_INITIATED', + + // 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', + CONSULT_ACCEPTED = 'CONSULT_ACCEPTED', + + // 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', + TRANSFER_SUCCESS = 'TRANSFER_SUCCESS', + TRANSFER_FAILED = 'TRANSFER_FAILED', + + // 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', + OUTBOUND_FAILED = 'OUTBOUND_FAILED', + + // Queue events + CTQ_CANCEL = 'CTQ_CANCEL', // Cancel To Queue + CTQ_CANCEL_FAILED = 'CTQ_CANCEL_FAILED', +} + +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/guards.ts b/packages/@webex/contact-center/src/services/task/state-machine/guards.ts new file mode 100644 index 00000000000..2a2ad3f0714 --- /dev/null +++ b/packages/@webex/contact-center/src/services/task/state-machine/guards.ts @@ -0,0 +1,49 @@ +/** + * Task State Machine Guards + * + * Guard functions that determine if a state transition is allowed. + * These functions validate the current context before allowing transitions. + * + * All guards now use a consistent object-based parameter structure for better + * maintainability, type safety, and extensibility. + */ + +import {StateValue} from 'xstate'; +import {TaskContext, TaskEventPayload} from './types'; + +/** + * Parameters passed to all guard functions + */ +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; +} + +/** + * 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}: GuardParams): boolean => { + return context.recordingControlsAvailable && context.recordingInProgress; + }, + + /** + * Check if recording is paused + */ + recordingPaused: ({context}: GuardParams): boolean => { + return context.recordingControlsAvailable && !context.recordingInProgress; + }, +}; 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..6f54e4d6136 --- /dev/null +++ b/packages/@webex/contact-center/src/services/task/state-machine/index.ts @@ -0,0 +1,27 @@ +/** + * Task State Machine + * + * Export all state machine components for easy importing + */ + +// Main state machine +export {getTaskStateMachineConfig, createTaskStateMachine} from './TaskStateMachine'; +export type {TaskStateMachine} from './TaskStateMachine'; + +// Types & enums +export {TaskState, TaskEvent, TaskAction} from './constants'; +export {isEventOfType} from './types'; +export type { + TaskContext, + TaskEventPayload, + TaskStateMachineConfig, + UIControls, + UIControlConfig, +} from './types'; + +// Guards +export {guards} from './guards'; +export type {GuardParams, GuardFunction} from './guards'; + +// 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 new file mode 100644 index 00000000000..570206426ed --- /dev/null +++ b/packages/@webex/contact-center/src/services/task/state-machine/types.ts @@ -0,0 +1,214 @@ +/** + * 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 {DestinationType, TaskChannelType, TaskData, TaskUIControls, VoiceVariant} from '../types'; +import {TaskEvent, TaskState} from './constants'; + +/** + * 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; +} + +/** + * UI Control configuration for the task + */ +export interface UIControlConfig { + /** Whether end call button is enabled (config option) */ + isEndTaskEnabled: boolean; + /** Whether end consult button is enabled (config option) */ + isEndConsultEnabled: boolean; + /** Channel type determines which controls are available */ + channelType: TaskChannelType; + /** Optional voice channel variant to toggle WebRTC-specific controls */ + 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 + * + * 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 + taskData: TaskData | null; + + // Consult tracking + acceptInitiated: boolean; + holdInitiated: boolean; + transferInitiated: boolean; + conferenceInitiated: boolean; + consultInitiator: boolean; + consultDestination: string | null; + consultDestinationAgentJoined: boolean; + + // Recording tracking derived from task data + recordingControlsAvailable: boolean; + recordingInProgress: boolean; + + // UI Control configuration (set at task creation) + uiControlConfig: UIControlConfig; + + // Computed UI controls (derived from state + context + config) + 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.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_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_INITIATED]: BaseEvent & {mediaResourceId: string}; + [TaskEvent.UNHOLD_SUCCESS]: BaseEvent & { + mediaResourceId: string; + taskData?: TaskData; + }; + [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; + taskData?: TaskData; + }; + [TaskEvent.CONSULT_END]: BaseEvent & {taskData?: TaskData}; + [TaskEvent.CONSULT_TRANSFER]: BaseEvent; + [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 & { + 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 & {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 & {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.OUTBOUND_FAILED]: BaseEvent & {reason?: string}; + [TaskEvent.CTQ_CANCEL]: BaseEvent & {taskData: TaskData}; + [TaskEvent.CTQ_CANCEL_FAILED]: BaseEvent & {taskData: TaskData}; +} + +/** + * Union of all possible event payloads + */ +export type TaskEventPayload = TaskEventPayloadMap[TaskEvent]; + +/** + * Type guard to check event type + */ +export function isEventOfType( + event: TaskEventPayload | undefined, + type: T +): event is TaskEventPayloadMap[T] { + return Boolean(event && event.type === type); +} + +/** + * Recording control state for UI controls computer + */ +export interface RecordingControlState { + available: boolean; + inProgress: boolean; +} + +/** + * State machine configuration type + */ +export interface TaskStateMachineConfig { + id: string; + initial: TaskState; + context: TaskContext; + states: Record; +} 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..b194d83748a --- /dev/null +++ b/packages/@webex/contact-center/src/services/task/state-machine/uiControlsComputer.ts @@ -0,0 +1,328 @@ +/** + * UI Controls Computer + * + * Centralized logic for computing UI control states based on: + * - State machine current state + * - State machine context + * - Configuration + */ + +import {TASK_CHANNEL_TYPE, TaskData, TaskUIControls, VOICE_VARIANT} from '../types'; +import {RecordingControlState, TaskContext, UIControlConfig} from './types'; +import {TaskState} from './constants'; + +/** + * Constant for a disabled/hidden control state + */ +const DISABLED_CONTROL = {isVisible: false, isEnabled: false} as const; + +function getRecordingControlState(context: TaskContext): RecordingControlState { + return { + available: Boolean(context.recordingControlsAvailable), + inProgress: Boolean(context.recordingInProgress), + }; +} + +/** + * Get default UI controls (all hidden/disabled) + */ +export function getDefaultUIControls(): TaskUIControls { + return { + 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, + }; +} + +/** + * Compute UI controls for voice channel + */ +function computeVoiceUIControls( + currentState: TaskState, + context: TaskContext, + config: UIControlConfig, + fallbackTaskData?: TaskData +): TaskUIControls { + const isWebrtc = config.voiceVariant === VOICE_VARIANT.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 {available: recordingAvailable, inProgress: recordingInProgress} = + getRecordingControlState(context); + const recordingFeatureEnabled = + config.channelType === TASK_CHANNEL_TYPE.VOICE && config.isRecordingEnabled; + 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.isEndTaskEnabled, + 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: recordingAvailable && recordingFeatureEnabled && (isConnected || isHeld), + isEnabled: recordingAvailable && recordingFeatureEnabled && recordingInProgress, + }, + + // 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; + + 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(); + } +} + +/** + * 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/taskDataNormalizer.ts b/packages/@webex/contact-center/src/services/task/taskDataNormalizer.ts new file mode 100644 index 00000000000..1f38e9a6922 --- /dev/null +++ b/packages/@webex/contact-center/src/services/task/taskDataNormalizer.ts @@ -0,0 +1,137 @@ +import { + CallProcessingBooleanKey, + InteractionBooleanKey, + ParticipantBooleanKey, + TaskData, +} from './types'; + +const booleanKeys: CallProcessingBooleanKey[] = [ + 'recordingStarted', + 'recordInProgress', + 'isPaused', + 'pauseResumeEnabled', + 'ctqInProgress', + 'outdialTransferToQueueEnabled', + 'taskToBeSelfServiced', + 'CONTINUE_RECORDING_ON_TRANSFER', + 'isParked', + 'participantInviteTimeout', + 'checkAgentAvailability', +]; + +const interactionBooleanKeys: InteractionBooleanKey[] = [ + 'isFcManaged', + 'isMediaForked', + 'isTerminated', +]; + +const participantBooleanKeys: ParticipantBooleanKey[] = [ + 'autoAnswerEnabled', + 'hasJoined', + 'hasLeft', + 'isConsulted', + 'isInPredial', + 'isOffered', + 'isWrapUp', + 'isWrappedUp', +]; + +const toBoolean = (value: unknown): 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; +}; + +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; + const participants = interaction.participants || {}; + Object.keys(participants).forEach((id) => { + const participant = participants[id]; + const normalized = normalizeFields(participant, participantBooleanKeys); + if (normalized) { + if (!updatedParticipants) { + updatedParticipants = {...participants}; + } + updatedParticipants[id] = normalized; + } + }); + + let updatedMedia: typeof interaction.media | undefined; + const mediaEntries = interaction.media || {}; + Object.keys(mediaEntries).forEach((id) => { + const media = mediaEntries[id]; + const normalized = normalizeFields(media, ['isHold']); + if (normalized) { + if (!updatedMedia) { + updatedMedia = {...mediaEntries}; + } + 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 fea840339b5..bf142bf722c 100644 --- a/packages/@webex/contact-center/src/services/task/types.ts +++ b/packages/@webex/contact-center/src/services/task/types.ts @@ -1,5 +1,8 @@ +/* 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 type {AnyActorRef} from 'xstate'; import {Msg} from '../core/GlobalTypes'; import AutoWrapup from './AutoWrapup'; @@ -90,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 @@ -205,6 +228,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 @@ -289,6 +317,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 @@ -499,6 +538,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; @@ -513,7 +671,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 */ @@ -522,7 +684,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; @@ -540,20 +713,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 */ @@ -641,6 +818,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, @@ -664,38 +843,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; @@ -705,7 +873,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; @@ -717,7 +885,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; @@ -727,7 +895,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; @@ -761,8 +929,107 @@ 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 incoming for the active agent + */ + isIncomingTask?: 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; +}; + +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 representation surfaced to task consumers. + * Mirrors the buttons available in Task.uiControls without extra metadata. + */ +export type TaskUIControls = { + accept: TaskUIControlState; + decline: TaskUIControlState; + hold: TaskUIControlState; + transfer: TaskUIControlState; + consult: TaskUIControlState; + end: TaskUIControlState; + recording: TaskUIControlState; + mute: TaskUIControlState; + consultTransfer: TaskUIControlState; + endConsult: TaskUIControlState; + conference: TaskUIControlState; + exitConference: TaskUIControlState; + transferConference: TaskUIControlState; + mergeToConference: TaskUIControlState; + wrapup: TaskUIControlState; +}; + +/** + * Helper class for managing task action control state + * Tracks visibility and enabled state for task actions that can be executed + * @public + */ /** * Type representing an agent contact message within the contact center system * Contains comprehensive interaction and task related details for agent operations @@ -1106,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, @@ -1138,6 +1440,22 @@ 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 + */ + stateMachineService?: AnyActorRef; + state?: any; + /** * Cancels the auto-wrapup timer for the task. * This method stops the auto-wrapup process if it is currently active. @@ -1334,3 +1652,82 @@ 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 + */ + uiControls: TaskUIControls; + + /** + * 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; +} + +/** + * 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 + * @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..d7092c37896 100644 --- a/packages/@webex/contact-center/src/services/task/voice/Voice.ts +++ b/packages/@webex/contact-center/src/services/task/voice/Voice.ts @@ -8,236 +8,39 @@ import { TaskData, TaskResponse, IVoice, + VoiceUIControlOptions, TransferPayLoad, ConsultTransferPayLoad, CONSULT_TRANSFER_DESTINATION_TYPE, + TASK_CHANNEL_TYPE, + TASK_EVENTS, + VOICE_VARIANT, } from '../types'; -import Task from '../Task'; -import {CC_EVENTS} from '../../config/types'; +import Task, {TaskRuntimeOptions} from '../Task'; import LoggerProxy from '../../../logger-proxy'; 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; - constructor( contact: ReturnType, data: TaskData, - callOptions: {isEndCallEnabled?: boolean; isEndConsultEnabled?: boolean} = {} + callOptions: VoiceUIControlOptions = {}, + runtimeOptions: TaskRuntimeOptions = {} ) { - super(contact, data); - // apply defaults when no explicit setting provided - this.isEndCallEnabled = callOptions.isEndCallEnabled ?? true; - this.isEndConsultEnabled = callOptions.isEndConsultEnabled ?? true; - } - - private applyConsultingControls(): void { - this.updateTaskUiControls({ - hold: [false, false], - transfer: [false, false], - consult: [false, false], - recording: [true, false], - }); - - if (!this.data.isConsulted) { - this.updateTaskUiControls({ - consultTransfer: [true, true], - endConsult: [true, true], - end: [this.isEndCallEnabled, false], - }); - } else { - this.updateTaskUiControls({endConsult: [this.isEndConsultEnabled, this.isEndConsultEnabled]}); - } - } - - protected setUIControls(): void { - const eventType = this.data.type; - - switch (eventType) { - case CC_EVENTS.AGENT_CONTACT_ASSIGNED: - this.updateTaskUiControls({ - accept: [false, false], - decline: [false, false], - hold: [true, true], - transfer: [true, true], - consult: [true, true], - recording: [true, true], - end: [this.isEndCallEnabled, this.isEndCallEnabled], - endConsult: [false, false], - wrapup: [false, false], - }); - break; - - case CC_EVENTS.AGENT_WRAPUP: - case CC_EVENTS.AGENT_CONTACT_UNASSIGNED: - this.updateTaskUiControls({ - consultTransfer: [false, false], - recording: [false, false], - end: [false, false], - endConsult: [false, false], - hold: [false, false], - transfer: [false, false], - consult: [false, false], - wrapup: [true, true], - }); - break; - - case CC_EVENTS.CONTACT_ENDED: - case CC_EVENTS.AGENT_INVITE_FAILED: - this.updateTaskUiControls({ - hold: [false, false], - transfer: [false, false], - consult: [false, false], - consultTransfer: [false, false], - recording: [false, false], - end: [false, false], - endConsult: [false, false], - }); - if (this.data.interaction.state !== 'new') { - this.updateTaskUiControls({wrapup: [true, true]}); - } - break; - - case CC_EVENTS.AGENT_CONTACT_HELD: - this.updateTaskUiControls({ - hold: [true, true], - transfer: [true, true], - consult: [true, true], - recording: [true, true], - end: [this.isEndCallEnabled, false], - }); - break; - - case CC_EVENTS.AGENT_CONTACT_UNHELD: - this.updateTaskUiControls({ - hold: [true, true], - transfer: [true, true], - consult: [true, true], - recording: [true, true], - end: [this.isEndCallEnabled, true], - }); - break; - - case CC_EVENTS.AGENT_VTEAM_TRANSFERRED: - this.updateTaskUiControls({ - hold: [false, false], - transfer: [false, false], - consult: [false, false], - consultTransfer: [false, false], - recording: [false, false], - end: [false, false], - wrapup: [true, true], - }); - break; - - case CC_EVENTS.AGENT_CTQ_CANCEL_FAILED: - this.updateTaskUiControls({ - hold: [true, true], - transfer: [true, true], - consult: [true, true], - recording: [true, true], - end: [this.isEndCallEnabled, true], - }); - break; - - case CC_EVENTS.AGENT_CONSULT_CREATED: - this.updateTaskUiControls({ - hold: [false, false], - consult: [false, false], - transfer: [true, false], - end: [this.isEndCallEnabled, false], - consultTransfer: [true, false], - recording: [true, false], - endConsult: [true, true], - }); - break; - - case CC_EVENTS.AGENT_OFFER_CONSULT: - this.updateTaskUiControls({ - endConsult: [this.isEndConsultEnabled, this.isEndConsultEnabled], - }); - break; - - case CC_EVENTS.AGENT_CONSULTING: - if (!this.data.isConsulted) { - this.updateTaskUiControls({ - hold: [false, false], - transfer: [true, false], - consult: [false, false], - consultTransfer: [true, true], - recording: [true, false], - endConsult: [true, true], - end: [this.isEndCallEnabled, false], - }); - } else { - this.updateTaskUiControls({ - endConsult: [this.isEndConsultEnabled, this.isEndConsultEnabled], - }); - } - break; - - case CC_EVENTS.AGENT_CONSULT_FAILED: - case CC_EVENTS.AGENT_CONSULT_ENDED: - if (!this.data.isConsulted) { - this.updateTaskUiControls({ - hold: [true, true], - transfer: [true, true], - consult: [true, true], - recording: [true, true], - end: [this.isEndCallEnabled, this.isEndCallEnabled], - consultTransfer: [false, false], - endConsult: [false, false], - wrapup: [false, false], - }); - } else { - this.updateTaskUiControls({ - endConsult: [false, false], - }); - } - break; - - case CC_EVENTS.AGENT_CTQ_CANCELLED: - this.updateTaskUiControls({ - hold: [true, true], - transfer: [true, true], - consult: [true, true], - recording: [true, true], - end: [this.isEndCallEnabled, this.isEndCallEnabled], - consultTransfer: [false, false], - endConsult: [false, false], - wrapup: [false, false], - }); - break; - - case CC_EVENTS.AGENT_CONTACT: - if (this.data.interaction.isTerminated) { - this.updateTaskUiControls({ - hold: [false, false], - transfer: [false, false], - consult: [false, false], - consultTransfer: [false, false], - recording: [false, false], - end: [false, false], - wrapup: [true, true], - }); - } else if (this.data.interaction.state === 'connected' && !this.data.isConsulted) { - this.updateTaskUiControls({ - hold: [true, true], - transfer: [true, true], - consult: [true, true], - recording: [true, true], - end: [this.isEndCallEnabled, this.isEndCallEnabled], - }); - } else if (this.data.interaction.state === 'consulting') { - this.applyConsultingControls(); - } - break; - - default: - break; - } + 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 + ); } /** @@ -260,6 +63,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 +106,40 @@ 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?.getSnapshot?.(); + if (state) { + const currentState = state.value as TaskState; + if (shouldHold) { + 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, + method: METHODS.HOLD_RESUME, + interactionId: this.data.interactionId, + }); + throw error; + } + } 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, + method: METHODS.HOLD_RESUME, + interactionId: this.data.interactionId, + }); + throw error; + } + } + + // 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, @@ -295,6 +158,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, { @@ -311,11 +183,20 @@ 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}, }); + + // 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, { @@ -336,6 +217,13 @@ export default class Voice extends Task implements IVoice { return response; } catch (error) { + 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, @@ -367,6 +255,18 @@ export default class Voice extends Task implements IVoice { * ``` */ public async pauseRecording(): Promise { + // Validate recording is active + 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', { + module: CC_FILE, + method: 'pauseRecording', + interactionId: this.data.interactionId, + }); + throw error; + } + try { LoggerProxy.info(`Pausing recording`, { module: CC_FILE, @@ -420,8 +320,20 @@ export default class Voice extends Task implements IVoice { * ``` */ public async resumeRecording( - resumeRecordingPayload: ResumeRecordingPayload + resumeRecordingPayload?: ResumeRecordingPayload ): Promise { + // Validate recording is paused + 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', { + module: CC_FILE, + method: 'resumeRecording', + interactionId: this.data.interactionId, + }); + throw error; + } + try { LoggerProxy.info(`Resuming recording`, { module: CC_FILE, @@ -483,7 +395,32 @@ 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 { + // Validate consult is allowed + const state = this.stateMachineService?.getSnapshot?.(); + const canConsult = + 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`); + LoggerProxy.error('Consult operation not allowed', { + module: CC_FILE, + method: 'consult', + interactionId: this.data.interactionId, + }); + 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, + }); + } + try { LoggerProxy.info(`Starting consult`, { module: CC_FILE, @@ -498,6 +435,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, { @@ -517,6 +463,11 @@ export default class Voice extends Task implements IVoice { return result; } catch (error) { + 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, @@ -547,7 +498,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 +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 24996275e55..0edd767204a 100644 --- a/packages/@webex/contact-center/src/services/task/voice/WebRTC.ts +++ b/packages/@webex/contact-center/src/services/task/voice/WebRTC.ts @@ -2,10 +2,17 @@ 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 { + TaskData, + TaskResponse, + TASK_EVENTS, + IWebRTC, + VoiceUIControlOptions, + VOICE_VARIANT, +} from '../types'; import Voice from './Voice'; +import type {TaskRuntimeOptions} from '../Task'; import WebCallingService from '../../WebCallingService'; -import {CC_EVENTS} from '../../config/types'; import MetricsManager from '../../../metrics/MetricsManager'; import {METRIC_EVENT_NAMES} from '../../../metrics/constants'; import LoggerProxy from '../../../logger-proxy'; @@ -18,10 +25,10 @@ export default class WebRTC extends Voice implements IWebRTC { contact: ReturnType, webCallingService: WebCallingService, data: TaskData, - callOptions: {isEndCallEnabled?: boolean; isEndConsultEnabled?: boolean} = {} + callOptions: VoiceUIControlOptions = {}, + runtimeOptions: TaskRuntimeOptions = {} ) { - super(contact, data, callOptions); - this.updateTaskUiControls({accept: [true, true], decline: [true, true]}); + super(contact, data, {...callOptions, voiceVariant: VOICE_VARIANT.WEBRTC}, runtimeOptions); this.webCallingService = webCallingService; this.registerWebCallListeners(); } @@ -34,91 +41,6 @@ export default class WebRTC extends Voice implements IWebRTC { this.emit(TASK_EVENTS.TASK_MEDIA, track); }; - /** - * This method is used to set the UI controls for the specific type of task - */ - protected setUIControls(): void { - 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; - - // 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; - } - } - /** * This method is used to unregister the web call listeners. * @returns void diff --git a/packages/@webex/contact-center/src/types.ts b/packages/@webex/contact-center/src/types.ts index 643e69386fc..efb9936f9b5 100644 --- a/packages/@webex/contact-center/src/types.ts +++ b/packages/@webex/contact-center/src/types.ts @@ -570,10 +570,15 @@ export type BuddyAgents = { * @internal */ export type ConfigFlags = { - isEndCallEnabled: boolean; + isEndTaskEnabled: boolean; isEndConsultEnabled: boolean; webRtcEnabled: boolean; autoWrapup: boolean; + /** + * Optional toggle to globally enable/disable recording controls. + * Falls back to backend hints when omitted. + */ + isRecordingEnabled?: boolean; }; /** diff --git a/packages/@webex/contact-center/test/unit/spec/cc.ts b/packages/@webex/contact-center/test/unit/spec/cc.ts index df0bfcb94ae..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, @@ -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..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 @@ -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', + isEndTaskEnabled: 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/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 bba8c44317e..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 @@ -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, }; @@ -335,7 +338,7 @@ describe('TaskManager', () => { contactMock, webCallingService, taskDataMock, - {isEndCallEnabled: true, isEndConsultEnabled: true} + {isEndTaskEnabled: true, isEndConsultEnabled: true} ); (taskManager as any).taskCollection[taskId] = webrtcTask; @@ -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..b1a7449249e --- /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, + isEndTaskEnabled: 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 2e7b6165fe5..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 @@ -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, { - isEndCallEnabled: false, + const voice = new Voice(dummyContact, createBaseData(), { + isEndTaskEnabled: 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, { - isEndCallEnabled: true, + const taskData = createBaseData(); + const voice = new Voice(dummyContact, taskData, { + isEndTaskEnabled: 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, { - isEndCallEnabled: true, + const taskData = createBaseData(); + const voice = new Voice(dummyContact, taskData, { + isEndTaskEnabled: 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, { - isEndCallEnabled: true, + const taskData = createBaseData(); + const voice = new Voice(dummyContact, taskData, { + isEndTaskEnabled: 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,14 +140,13 @@ 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, - { isEndCallEnabled: true, isEndConsultEnabled: true } + { isEndTaskEnabled: true, isEndConsultEnabled: true } ); const result = await voice.transfer({ @@ -128,12 +161,12 @@ 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, + isEndTaskEnabled: true, isEndConsultEnabled: true, }); @@ -147,15 +180,14 @@ 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, - { isEndCallEnabled: true, isEndConsultEnabled: true } + { isEndTaskEnabled: true, isEndConsultEnabled: true } ); const result = await voice.transfer({ @@ -178,8 +210,8 @@ describe('Voice Task', () => { const consultEndMock = jest.fn().mockResolvedValue('endedC'); const voice = new Voice( { ...dummyContact, consultEnd: consultEndMock }, - baseData, - { isEndCallEnabled: true, isEndConsultEnabled: true } + createBaseData(), + { isEndTaskEnabled: true, isEndConsultEnabled: true } ); const payload = { isConsult: true, queueId: 'q1', taskId: 't1' }; const result = await voice.endConsult(payload); @@ -194,229 +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, + isEndTaskEnabled: true, isEndConsultEnabled: false, }); voice.updateTaskData(data); - - expect(voice.taskUiControls.accept.visible).toBe(false); - expect(voice.taskUiControls.decline.visible).toBe(false); - expect(voice.taskUiControls.hold.visible).toBe(true); - expect(voice.taskUiControls.transfer.visible).toBe(true); - expect(voice.taskUiControls.consult.visible).toBe(true); - expect(voice.taskUiControls.recording.visible).toBe(true); - expect(voice.taskUiControls.end.visible).toBe(true); - expect(voice.taskUiControls.endConsult.visible).toBe(false); + primeConnectedState(voice, data); + + 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) + 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(voice.uiControls).toEqual(initialExpected); - it('CONTACT_ENDED with state active hides all except wrapup', () => { - const ctrl = make(CC_EVENTS.CONTACT_ENDED, { state: 'ended' }); - ['hold', 'transfer', 'consult', 'consultTransfer', 'recording', 'end', 'endConsult'].forEach( - (k) => expect((ctrl as any)[k].visible).toBe(false) - ); - expect(ctrl.wrapup.visible).toBe(true); - expect(ctrl.wrapup.enabled).toBe(true); - }); - - it('AGENT_CONTACT_HELD shows main controls and end disabled', () => { - const ctrl = make(CC_EVENTS.AGENT_CONTACT_HELD); - ['hold', 'transfer', 'consult', 'recording'].forEach((k) => - expect((ctrl as any)[k].visible).toBe(true) - ); - expect(ctrl.end.visible).toBe(true); - expect(ctrl.end.enabled).toBe(false); - }); + voice.updateTaskData(taskData); + voice.stateMachineService?.send({type: TaskEvent.OFFER, taskData}); + voice.stateMachineService?.send({type: TaskEvent.ASSIGN, taskData}); - 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) + 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); + expect(voice.uiControls).toEqual(expected); }); + }); - 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); - }); + describe('recording operations', () => { + const buildRecordingData = (recordingOverrides: Record) => + createBaseData({ + interaction: { + callProcessingDetails: recordingOverrides, + } as any, + }); - it('AGENT_CTQ_CANCEL_FAILED shows main and end enabled', () => { - const ctrl = make(CC_EVENTS.AGENT_CTQ_CANCEL_FAILED); - ['hold', 'transfer', 'consult', 'recording'].forEach((k) => - expect((ctrl as any)[k].visible).toBe(true) - ); - expect(ctrl.end.visible).toBe(true); - expect(ctrl.end.enabled).toBe(true); + it('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('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('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('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); + await voice.pauseRecording(); + expect(dummyContact.pauseRecording).toHaveBeenCalledWith({interactionId: 'int1'}); }); - 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('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('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); + await expect(voice.resumeRecording()).rejects.toThrow('Recording is not paused'); + expect(dummyContact.resumeRecording).not.toHaveBeenCalled(); }); - 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); - }); - }); + 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}); - describe('UI controls for AGENT_CONTACT', () => { - const makeContact = (opts: { - state: string; - isConsulted?: boolean; - isTerminated?: boolean; - endCall?: boolean; - endConsult?: boolean; - }) => { - const data: any = { - ...baseData, - type: CC_EVENTS.AGENT_CONTACT, - interaction: { - ...baseData.interaction, - state: opts.state, - isTerminated: opts.isTerminated || false, - }, - isConsulted: opts.isConsulted || false, - }; - const voice = new Voice(dummyContact, data, { - isEndCallEnabled: opts.endCall ?? true, - isEndConsultEnabled: opts.endConsult ?? true, + await voice.resumeRecording(); + expect(dummyContact.resumeRecording).toHaveBeenCalledWith({ + interactionId: 'int1', + data: {autoResumed: false}, }); - voice.updateTaskData(data); - return voice.taskUiControls; - }; - - it('hides all and shows wrapup when terminated', () => { - const ctrl = makeContact({ state: 'connected', isTerminated: true }); - ['hold', 'transfer', 'consult', 'consultTransfer', 'recording', 'end'].forEach((k) => - expect((ctrl as any)[k].visible).toBe(false) - ); - expect(ctrl.wrapup.visible).toBe(true); - expect(ctrl.wrapup.enabled).toBe(true); - }); - - it('shows main and end enabled when connected (not consulted)', () => { - const ctrl = makeContact({ state: 'connected', isConsulted: false }); - ['hold', 'transfer', 'consult', 'recording'].forEach((k) => - expect((ctrl as any)[k].visible).toBe(true) - ); - expect(ctrl.end.visible).toBe(true); - expect(ctrl.end.enabled).toBe(true); - }); - - it('consulting (not consulted) hides main, shows consultTransfer/endConsult, end disabled', () => { - const ctrl = makeContact({ state: 'consulting', isConsulted: false, endCall: true }); - ['hold', 'transfer', 'consult'].forEach((k) => - expect((ctrl as any)[k].visible).toBe(false) - ); - expect(ctrl.consultTransfer.visible).toBe(true); - expect(ctrl.consultTransfer.enabled).toBe(true); - expect(ctrl.endConsult.visible).toBe(true); - expect(ctrl.endConsult.enabled).toBe(true); - expect(ctrl.end.visible).toBe(true); - expect(ctrl.end.enabled).toBe(false); - }); - - it('consulting (consulted) hides main and shows only endConsult when allowed', () => { - const ctrl = makeContact({ state: 'consulting', isConsulted: true, endConsult: true }); - ['hold', 'transfer', 'consult', 'consultTransfer'].forEach((k) => - expect((ctrl as any)[k].visible).toBe(false) - ); - expect(ctrl.recording.visible).toBe(true); - expect(ctrl.recording.enabled).toBe(false); - expect(ctrl.endConsult.visible).toBe(true); - expect(ctrl.endConsult.enabled).toBe(true); - expect(ctrl.end.visible).toBe(false); }); }); }); diff --git a/packages/@webex/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..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 @@ -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, { + isEndTaskEnabled: 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, { - isEndCallEnabled: true, + taskData = createTaskData(); + webRtc = new WebRTC(dummyContact, webCallingService, taskData, { + isEndTaskEnabled: 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'; diff --git a/yarn.lock b/yarn.lock index 42871ead92a..45cefb31eb5 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 @@ -9069,7 +9070,8 @@ __metadata: lodash: ^4.17.21 prettier: 2.5.1 typedoc: ^0.25.0 - typescript: 4.9.5 + typescript: 5.4.5 + xstate: 5.24.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" @@ -34132,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" @@ -34142,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" @@ -34182,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" @@ -34192,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" @@ -35566,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 @@ -36427,6 +36485,13 @@ __metadata: languageName: node linkType: hard +"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"