@webex/contact-center 3.12.0-task-refactor.3 → 3.12.0-task-refactor.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/services/task/TaskManager.js +9 -2
- package/dist/services/task/TaskManager.js.map +1 -1
- package/dist/services/task/state-machine/TaskStateMachine.js +51 -9
- package/dist/services/task/state-machine/TaskStateMachine.js.map +1 -1
- package/dist/services/task/state-machine/actions.js +11 -6
- package/dist/services/task/state-machine/actions.js.map +1 -1
- package/dist/services/task/state-machine/constants.js +20 -1
- package/dist/services/task/state-machine/constants.js.map +1 -1
- package/dist/services/task/state-machine/guards.js +27 -8
- package/dist/services/task/state-machine/guards.js.map +1 -1
- package/dist/services/task/state-machine/uiControlsComputer.js +45 -12
- package/dist/services/task/state-machine/uiControlsComputer.js.map +1 -1
- package/dist/services/task/types.js.map +1 -1
- package/dist/services/task/voice/Voice.js +16 -5
- package/dist/services/task/voice/Voice.js.map +1 -1
- package/dist/types/services/task/state-machine/TaskStateMachine.d.ts +52 -4
- package/dist/types/services/task/state-machine/constants.d.ts +13 -0
- package/dist/types/services/task/state-machine/guards.d.ts +6 -1
- package/dist/types/services/task/types.d.ts +2 -0
- package/dist/types/services/task/voice/Voice.d.ts +18 -17
- package/dist/webex.js +1 -1
- package/package.json +1 -1
- package/src/services/task/TaskManager.ts +9 -3
- package/src/services/task/state-machine/TaskStateMachine.ts +79 -10
- package/src/services/task/state-machine/actions.ts +19 -10
- package/src/services/task/state-machine/constants.ts +19 -0
- package/src/services/task/state-machine/guards.ts +34 -7
- package/src/services/task/state-machine/uiControlsComputer.ts +45 -17
- package/src/services/task/types.ts +2 -0
- package/src/services/task/voice/Voice.ts +20 -11
- package/test/unit/spec/services/task/TaskManager.ts +26 -0
- package/test/unit/spec/services/task/state-machine/TaskStateMachine.ts +164 -0
- package/test/unit/spec/services/task/state-machine/guards.ts +103 -0
- package/test/unit/spec/services/task/state-machine/uiControlsComputer.ts +237 -1
- package/test/unit/spec/services/task/voice/Voice.ts +24 -0
- package/umd/contact-center.min.js +2 -2
- package/umd/contact-center.min.js.map +1 -1
|
@@ -291,7 +291,7 @@ export default class TaskManager extends EventEmitter {
|
|
|
291
291
|
return {type: TaskEvent.RONA, taskData: payload, reason: payload.reason};
|
|
292
292
|
|
|
293
293
|
case CC_EVENTS.AGENT_OUTBOUND_FAILED:
|
|
294
|
-
return {type: TaskEvent.OUTBOUND_FAILED, reason: payload.reason};
|
|
294
|
+
return {type: TaskEvent.OUTBOUND_FAILED, taskData: payload, reason: payload.reason};
|
|
295
295
|
|
|
296
296
|
case CC_EVENTS.CONTACT_RECORDING_STARTED:
|
|
297
297
|
return {type: TaskEvent.RECORDING_STARTED, taskData: payload};
|
|
@@ -307,6 +307,7 @@ export default class TaskManager extends EventEmitter {
|
|
|
307
307
|
|
|
308
308
|
// Conference events - these trigger state machine transition to CONFERENCING
|
|
309
309
|
case CC_EVENTS.AGENT_CONSULT_CONFERENCED:
|
|
310
|
+
case CC_EVENTS.AGENT_CONSULT_CONFERENCING:
|
|
310
311
|
case CC_EVENTS.PARTICIPANT_JOINED_CONFERENCE:
|
|
311
312
|
return {type: TaskEvent.CONFERENCE_START, taskData: payload};
|
|
312
313
|
|
|
@@ -375,6 +376,12 @@ export default class TaskManager extends EventEmitter {
|
|
|
375
376
|
task.sendStateMachineEvent(stateMachineEvent);
|
|
376
377
|
}
|
|
377
378
|
|
|
379
|
+
// Emit TASK_POST_CALL_ACTIVITY for ParticipantPostCallActivity events so
|
|
380
|
+
// consumers (Widgets) can detect the interaction state change to post_call.
|
|
381
|
+
if (eventContext.eventType === CC_EVENTS.PARTICIPANT_POST_CALL_ACTIVITY) {
|
|
382
|
+
task.emit(TASK_EVENTS.TASK_POST_CALL_ACTIVITY, task);
|
|
383
|
+
}
|
|
384
|
+
|
|
378
385
|
// Send transcript start/stop events for relevant CC events
|
|
379
386
|
this.requestRealTimeTranscripts(eventContext.eventType, payload.interactionId);
|
|
380
387
|
});
|
|
@@ -655,8 +662,7 @@ export default class TaskManager extends EventEmitter {
|
|
|
655
662
|
const {payload} = context;
|
|
656
663
|
let task = context.task;
|
|
657
664
|
|
|
658
|
-
if (payload.childInteractionId) {
|
|
659
|
-
// remove the child task from collection
|
|
665
|
+
if (payload.childInteractionId && this.taskCollection[payload.childInteractionId]) {
|
|
660
666
|
this.removeTaskFromCollection(this.taskCollection[payload.childInteractionId]);
|
|
661
667
|
}
|
|
662
668
|
|
|
@@ -12,7 +12,7 @@ import {setup} from 'xstate';
|
|
|
12
12
|
import {TaskContext, TaskEventPayload, UIControlConfig, TaskActionsMap} from './types';
|
|
13
13
|
import {TaskState, TaskEvent} from './constants';
|
|
14
14
|
import {actions, createInitialContext} from './actions';
|
|
15
|
-
import {guards} from './guards';
|
|
15
|
+
import {guards, shouldWrapUpForThisAgent, getTaskDataFromEvent} from './guards';
|
|
16
16
|
import {getIsCustomerInCall} from '../TaskUtils';
|
|
17
17
|
|
|
18
18
|
type TaskActionConfigMap = {[K in keyof typeof actions]: undefined};
|
|
@@ -123,6 +123,12 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
|
|
|
123
123
|
actions: ['initializeTask', 'emitTaskIncoming'],
|
|
124
124
|
},
|
|
125
125
|
|
|
126
|
+
// AgentOutboundFailed can arrive before TASK_INCOMING due to race conditions
|
|
127
|
+
[TaskEvent.OUTBOUND_FAILED]: {
|
|
128
|
+
target: TaskState.TERMINATED,
|
|
129
|
+
actions: ['updateTaskData', 'markEnded', 'emitTaskOutdialFailed', 'emitTaskEnd'],
|
|
130
|
+
},
|
|
131
|
+
|
|
126
132
|
// EP-DN split-leg ordering can deliver AgentConsulting before HYDRATE/TASK_INCOMING.
|
|
127
133
|
// Do not drop it in IDLE; bootstrap to CONSULTING using event taskData.
|
|
128
134
|
[TaskEvent.CONSULTING_ACTIVE]: {
|
|
@@ -190,7 +196,7 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
|
|
|
190
196
|
},
|
|
191
197
|
{
|
|
192
198
|
target: TaskState.TERMINATED,
|
|
193
|
-
actions: ['updateTaskData', 'markEnded', 'emitTaskOutdialFailed', '
|
|
199
|
+
actions: ['updateTaskData', 'markEnded', 'emitTaskOutdialFailed', 'emitTaskEnd'],
|
|
194
200
|
},
|
|
195
201
|
],
|
|
196
202
|
// AgentConsulting comes for received after the initial consult is accepted
|
|
@@ -467,6 +473,24 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
|
|
|
467
473
|
target: TaskState.CONNECTED,
|
|
468
474
|
actions: ['updateTaskData', 'clearConsultState', 'emitTaskConsultEnd'],
|
|
469
475
|
},
|
|
476
|
+
{
|
|
477
|
+
// Customer left during consult → WRAPPING_UP
|
|
478
|
+
guard: ({context, event}) => {
|
|
479
|
+
const taskData = getTaskDataFromEvent(event);
|
|
480
|
+
const cpd = taskData?.interaction?.callProcessingDetails;
|
|
481
|
+
if (cpd?.hasCustomerLeft !== 'true') return false;
|
|
482
|
+
|
|
483
|
+
return shouldWrapUpForThisAgent(context, taskData);
|
|
484
|
+
},
|
|
485
|
+
target: TaskState.WRAPPING_UP,
|
|
486
|
+
actions: [
|
|
487
|
+
'updateTaskData',
|
|
488
|
+
'markEnded',
|
|
489
|
+
'clearConsultState',
|
|
490
|
+
'emitTaskWrapup',
|
|
491
|
+
'requestCleanup',
|
|
492
|
+
],
|
|
493
|
+
},
|
|
470
494
|
{
|
|
471
495
|
// Initiator (no conference) → HELD
|
|
472
496
|
guard: ({context}) => context.consultInitiator === true,
|
|
@@ -587,30 +611,61 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
|
|
|
587
611
|
// AgentConsultConferenced, ParticipantJoinedConference
|
|
588
612
|
[TaskEvent.CONFERENCE_START]: {
|
|
589
613
|
target: TaskState.CONFERENCING,
|
|
590
|
-
actions: [
|
|
614
|
+
actions: [
|
|
615
|
+
'updateTaskData',
|
|
616
|
+
'syncTaskDataFromEvent',
|
|
617
|
+
'handleConferenceStarted',
|
|
618
|
+
'clearConsultState',
|
|
619
|
+
],
|
|
591
620
|
},
|
|
592
621
|
},
|
|
593
622
|
},
|
|
594
623
|
|
|
595
624
|
[TaskState.CONF_INITIATING]: {
|
|
596
625
|
on: {
|
|
597
|
-
// AgentConsultConferenced, ParticipantJoinedConference
|
|
626
|
+
// AgentConsultConferenced, AgentConsultConferencing, ParticipantJoinedConference
|
|
598
627
|
[TaskEvent.CONFERENCE_START]: {
|
|
599
628
|
target: TaskState.CONFERENCING,
|
|
600
|
-
actions: [
|
|
629
|
+
actions: [
|
|
630
|
+
'updateTaskData',
|
|
631
|
+
'syncTaskDataFromEvent',
|
|
632
|
+
'handleConferenceStarted',
|
|
633
|
+
'clearConsultState',
|
|
634
|
+
],
|
|
601
635
|
},
|
|
602
636
|
// AgentConsultConferenceFailed
|
|
603
637
|
[TaskEvent.CONFERENCE_FAILED]: {
|
|
604
638
|
target: TaskState.CONSULTING,
|
|
605
639
|
actions: ['handleConferenceFailed', 'emitTaskConferenceFailed'],
|
|
606
640
|
},
|
|
641
|
+
// AgentConsultEnded while conference is initiating (end call before conference completes)
|
|
642
|
+
[TaskEvent.CONSULT_END]: [
|
|
643
|
+
{
|
|
644
|
+
guard: ({event}) => {
|
|
645
|
+
const taskData = getTaskDataFromEvent(event);
|
|
646
|
+
|
|
647
|
+
return taskData?.interaction?.isTerminated === true;
|
|
648
|
+
},
|
|
649
|
+
target: TaskState.WRAPPING_UP,
|
|
650
|
+
actions: ['updateTaskData', 'markEnded', 'clearConsultState', 'emitTaskWrapup'],
|
|
651
|
+
},
|
|
652
|
+
{
|
|
653
|
+
target: TaskState.CONNECTED,
|
|
654
|
+
actions: ['updateTaskData', 'clearConsultState'],
|
|
655
|
+
},
|
|
656
|
+
],
|
|
607
657
|
},
|
|
608
658
|
},
|
|
609
659
|
|
|
610
660
|
[TaskState.CONFERENCING]: {
|
|
611
661
|
on: {
|
|
612
662
|
[TaskEvent.CONFERENCE_START]: {
|
|
613
|
-
actions: [
|
|
663
|
+
actions: [
|
|
664
|
+
'updateTaskData',
|
|
665
|
+
'syncTaskDataFromEvent',
|
|
666
|
+
'clearConsultState',
|
|
667
|
+
'emitTaskConferenceStarted',
|
|
668
|
+
],
|
|
614
669
|
},
|
|
615
670
|
[TaskEvent.EXIT_CONFERENCE_SUCCESS]: [
|
|
616
671
|
{
|
|
@@ -625,15 +680,29 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
|
|
|
625
680
|
],
|
|
626
681
|
|
|
627
682
|
// Needed as all agents in conference get this event, hence we need to clear the consult state
|
|
628
|
-
[TaskEvent.CONSULT_END]:
|
|
629
|
-
|
|
630
|
-
|
|
683
|
+
[TaskEvent.CONSULT_END]: [
|
|
684
|
+
{
|
|
685
|
+
guard: ({context, event}) => {
|
|
686
|
+
const taskData = getTaskDataFromEvent(event);
|
|
687
|
+
|
|
688
|
+
return (
|
|
689
|
+
taskData?.interaction?.isTerminated === true &&
|
|
690
|
+
shouldWrapUpForThisAgent(context, taskData)
|
|
691
|
+
);
|
|
692
|
+
},
|
|
693
|
+
target: TaskState.WRAPPING_UP,
|
|
694
|
+
actions: ['updateTaskData', 'markEnded', 'clearConsultState', 'emitTaskWrapup'],
|
|
695
|
+
},
|
|
696
|
+
{
|
|
697
|
+
actions: ['updateTaskData', 'clearConsultState'],
|
|
698
|
+
},
|
|
699
|
+
],
|
|
631
700
|
|
|
632
701
|
[TaskEvent.HOLD_SUCCESS]: {
|
|
633
702
|
actions: ['updateTaskData', 'setHoldState', 'emitTaskHold'],
|
|
634
703
|
},
|
|
635
704
|
[TaskEvent.UNHOLD_SUCCESS]: {
|
|
636
|
-
actions: ['updateTaskData', 'setHoldState', 'emitTaskResume'],
|
|
705
|
+
actions: ['updateTaskData', 'syncTaskDataFromEvent', 'setHoldState', 'emitTaskResume'],
|
|
637
706
|
},
|
|
638
707
|
|
|
639
708
|
// Start a new consult from within an active conference
|
|
@@ -11,9 +11,10 @@ import {
|
|
|
11
11
|
TaskActionArgs,
|
|
12
12
|
RecordingStateUpdate,
|
|
13
13
|
} from './types';
|
|
14
|
-
import {TaskEvent, TaskState} from './constants';
|
|
14
|
+
import {TaskEvent, TaskState, INTERACTION_STATE} from './constants';
|
|
15
15
|
import {DestinationType, TaskData} from '../types';
|
|
16
16
|
import {computeUIControls, getDefaultUIControls} from './uiControlsComputer';
|
|
17
|
+
import {hasActiveConsultInPostCall} from './guards';
|
|
17
18
|
|
|
18
19
|
const determineConsultInitiator = (
|
|
19
20
|
taskData: TaskData | undefined,
|
|
@@ -81,27 +82,32 @@ const deriveTaskDataUpdates = (context: TaskContext, taskData: TaskData | undefi
|
|
|
81
82
|
...deriveRecordingState(taskData),
|
|
82
83
|
};
|
|
83
84
|
|
|
85
|
+
const selfAgentId = context.uiControlConfig.agentId ?? taskData?.agentId;
|
|
86
|
+
const consultingActive =
|
|
87
|
+
taskData?.interaction?.state === INTERACTION_STATE.CONSULTING ||
|
|
88
|
+
hasActiveConsultInPostCall(taskData, selfAgentId);
|
|
89
|
+
|
|
84
90
|
if (taskData.destAgentId) {
|
|
85
|
-
|
|
91
|
+
const isEpDnWithStoredId =
|
|
92
|
+
context.consultDestinationType === 'entryPoint' && context.consultDestinationAgentId;
|
|
93
|
+
if (!isEpDnWithStoredId) {
|
|
94
|
+
updates.consultDestinationAgentId = taskData.destAgentId;
|
|
95
|
+
}
|
|
86
96
|
}
|
|
87
|
-
if (
|
|
97
|
+
if (consultingActive && taskData.destinationType) {
|
|
88
98
|
updates.consultDestinationType = taskData.destinationType as DestinationType;
|
|
89
99
|
}
|
|
90
100
|
|
|
91
101
|
if (!context.consultInitiator) {
|
|
92
|
-
const selfAgentId = context.uiControlConfig.agentId ?? taskData?.agentId;
|
|
93
102
|
const consultInitiator = determineConsultInitiator(taskData, selfAgentId);
|
|
94
103
|
if (consultInitiator !== undefined) {
|
|
95
104
|
updates.consultInitiator = consultInitiator;
|
|
96
|
-
} else if (
|
|
97
|
-
taskData.interaction?.state === 'consulting' &&
|
|
98
|
-
taskData.isConsulted === false
|
|
99
|
-
) {
|
|
105
|
+
} else if (consultingActive && taskData.isConsulted === false) {
|
|
100
106
|
updates.consultInitiator = true;
|
|
101
107
|
}
|
|
102
108
|
}
|
|
103
109
|
|
|
104
|
-
if (taskData.interaction
|
|
110
|
+
if (consultingActive && taskData.interaction) {
|
|
105
111
|
if (!context.consultDestinationAgentJoined) {
|
|
106
112
|
const hasJoinedConsultee = Boolean(
|
|
107
113
|
taskData.interaction.participants &&
|
|
@@ -230,7 +236,10 @@ export const actions: TaskActionsMap = {
|
|
|
230
236
|
const taskData = getTaskDataFromEvent(event);
|
|
231
237
|
const consultDestinationType =
|
|
232
238
|
'destinationType' in event ? event.destinationType ?? null : null;
|
|
233
|
-
const consultDestinationAgentId =
|
|
239
|
+
const consultDestinationAgentId =
|
|
240
|
+
('destAgentId' in event ? event.destAgentId : null) ??
|
|
241
|
+
('destination' in event ? (event as any).destination : null) ??
|
|
242
|
+
null;
|
|
234
243
|
|
|
235
244
|
return {
|
|
236
245
|
consultDestinationType,
|
|
@@ -36,6 +36,25 @@ export const MEDIA_TYPE_CONSULT = 'consult';
|
|
|
36
36
|
/** Media type for main calls */
|
|
37
37
|
export const MEDIA_TYPE_MAIN_CALL = 'mainCall';
|
|
38
38
|
|
|
39
|
+
// ============================================
|
|
40
|
+
// Backend Interaction State Constants
|
|
41
|
+
// ============================================
|
|
42
|
+
|
|
43
|
+
/** Backend interaction state values (from server payloads) */
|
|
44
|
+
export const INTERACTION_STATE = {
|
|
45
|
+
CONSULTING: 'consulting',
|
|
46
|
+
POST_CALL: 'post_call',
|
|
47
|
+
CONFERENCE: 'conference',
|
|
48
|
+
CONNECTED: 'connected',
|
|
49
|
+
NEW: 'new',
|
|
50
|
+
} as const;
|
|
51
|
+
|
|
52
|
+
/** Backend participant consultState values */
|
|
53
|
+
export const CONSULT_STATE = {
|
|
54
|
+
CONSULTING: 'consulting',
|
|
55
|
+
CONFERENCING: 'conferencing',
|
|
56
|
+
} as const;
|
|
57
|
+
|
|
39
58
|
// ============================================
|
|
40
59
|
// State Machine Enums
|
|
41
60
|
// ============================================
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
getConferenceParticipantsCount,
|
|
28
28
|
getIsConferenceInProgress,
|
|
29
29
|
} from '../TaskUtils';
|
|
30
|
-
import {TaskEvent} from './constants';
|
|
30
|
+
import {TaskEvent, INTERACTION_STATE, CONSULT_STATE, MEDIA_TYPE_CONSULT} from './constants';
|
|
31
31
|
|
|
32
32
|
export const getTaskDataFromEvent = (event?: TaskEventPayload): TaskData | undefined =>
|
|
33
33
|
event && typeof event === 'object' && 'taskData' in event
|
|
@@ -44,6 +44,24 @@ export const isSelfConsultingAgent = (context: TaskContext, taskData?: TaskData)
|
|
|
44
44
|
return taskData?.consultingAgentId === selfAgentId;
|
|
45
45
|
};
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Detects an active consult during post_call state (customer left but agents still consulting).
|
|
49
|
+
* Shared by hydration guard (isInteractionConsulting) and action (deriveTaskDataUpdates).
|
|
50
|
+
*/
|
|
51
|
+
export const hasActiveConsultInPostCall = (
|
|
52
|
+
taskData: TaskData | undefined,
|
|
53
|
+
selfAgentId?: string
|
|
54
|
+
): boolean => {
|
|
55
|
+
if (taskData?.interaction?.state !== INTERACTION_STATE.POST_CALL || !selfAgentId) return false;
|
|
56
|
+
|
|
57
|
+
const selfParticipant = taskData.interaction?.participants?.[selfAgentId];
|
|
58
|
+
const hasConsultMedia = Object.values(taskData.interaction?.media ?? {}).some(
|
|
59
|
+
(media) => (media as {mType?: string})?.mType === MEDIA_TYPE_CONSULT
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
return selfParticipant?.consultState === CONSULT_STATE.CONSULTING && hasConsultMedia;
|
|
63
|
+
};
|
|
64
|
+
|
|
47
65
|
/**
|
|
48
66
|
* Determines if this agent should enter WRAPPING_UP state.
|
|
49
67
|
* Priority: agentsPendingWrapUp > wrapUpRequired / participant.isWrapUp > ownership > !isConsulted
|
|
@@ -97,14 +115,23 @@ export const guards = {
|
|
|
97
115
|
return false;
|
|
98
116
|
},
|
|
99
117
|
|
|
100
|
-
isInteractionConsulting: ({event}: GuardParams): boolean => {
|
|
118
|
+
isInteractionConsulting: ({event, context}: GuardParams): boolean => {
|
|
101
119
|
const taskData = getTaskDataFromEvent(event);
|
|
102
120
|
|
|
103
|
-
if (taskData?.interaction?.state ===
|
|
121
|
+
if (taskData?.interaction?.state === INTERACTION_STATE.CONSULTING) return true;
|
|
104
122
|
|
|
105
123
|
// EP_DN consulted agent: backend reports state as 'connected' but CPD indicates consult
|
|
106
124
|
const cpd = taskData?.interaction?.callProcessingDetails;
|
|
107
|
-
if (
|
|
125
|
+
if (
|
|
126
|
+
cpd?.relationshipType === 'consult' &&
|
|
127
|
+
taskData?.interaction?.state === INTERACTION_STATE.CONNECTED
|
|
128
|
+
) {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Customer left during consult: interaction state is "post_call" but consult
|
|
133
|
+
// between agents is still active. Detect via agent's consultState + consult media.
|
|
134
|
+
if (hasActiveConsultInPostCall(taskData, getSelfAgentId(context, taskData))) {
|
|
108
135
|
return true;
|
|
109
136
|
}
|
|
110
137
|
|
|
@@ -127,7 +154,7 @@ export const guards = {
|
|
|
127
154
|
isInteractionConnected: ({event}: GuardParams): boolean => {
|
|
128
155
|
const taskData = getTaskDataFromEvent(event);
|
|
129
156
|
|
|
130
|
-
return taskData?.interaction?.state ===
|
|
157
|
+
return taskData?.interaction?.state === INTERACTION_STATE.CONNECTED;
|
|
131
158
|
},
|
|
132
159
|
|
|
133
160
|
isConferencingByParticipants: ({event}: GuardParams): boolean => {
|
|
@@ -176,7 +203,7 @@ export const guards = {
|
|
|
176
203
|
if (!mainCallId) return false;
|
|
177
204
|
|
|
178
205
|
// Don't downgrade while backend still reports conference.
|
|
179
|
-
if (taskData.interaction.state ===
|
|
206
|
+
if (taskData.interaction.state === INTERACTION_STATE.CONFERENCE) return false;
|
|
180
207
|
|
|
181
208
|
const agentParticipantsCount = getConferenceParticipantsCount(taskData.interaction, mainCallId);
|
|
182
209
|
if (agentParticipantsCount >= 2) return false;
|
|
@@ -218,7 +245,7 @@ export const guards = {
|
|
|
218
245
|
return (
|
|
219
246
|
taskData.isConsulted === true ||
|
|
220
247
|
relationshipType === 'consult' ||
|
|
221
|
-
taskData.interaction?.state ===
|
|
248
|
+
taskData.interaction?.state === INTERACTION_STATE.CONSULTING
|
|
222
249
|
);
|
|
223
250
|
},
|
|
224
251
|
|
|
@@ -182,16 +182,18 @@ function computeVoiceInteractionUIControls(
|
|
|
182
182
|
|
|
183
183
|
return {
|
|
184
184
|
// Accept/Decline: Voice tasks in offered state
|
|
185
|
-
//
|
|
186
|
-
//
|
|
185
|
+
// Desktop/WebRTC + inbound: accept enabled (agent manually accepts)
|
|
186
|
+
// Desktop/WebRTC + outdial: accept disabled (auto-answer handles it; Widgets show "Accept" disabled)
|
|
187
|
+
// Extension mode (non-WebRTC): accept disabled (Widgets show "Ringing...")
|
|
187
188
|
accept:
|
|
188
189
|
state === TaskState.OFFERED && !interaction?.isTerminated
|
|
189
190
|
? {isVisible: true, isEnabled: isWebrtc && !isOutdial}
|
|
190
191
|
: DISABLED,
|
|
191
|
-
decline:
|
|
192
|
-
isWebrtc
|
|
193
|
-
|
|
194
|
-
|
|
192
|
+
decline: (() => {
|
|
193
|
+
if (!isWebrtc || state !== TaskState.OFFERED || interaction?.isTerminated) return DISABLED;
|
|
194
|
+
|
|
195
|
+
return isOutdial ? VISIBLE_DISABLED : VISIBLE_ENABLED;
|
|
196
|
+
})(),
|
|
195
197
|
|
|
196
198
|
// Hold: visible in connected/held/conference, disabled in conference/consulting
|
|
197
199
|
hold: (() => {
|
|
@@ -251,19 +253,26 @@ function computeVoiceInteractionUIControls(
|
|
|
251
253
|
return DISABLED;
|
|
252
254
|
})(),
|
|
253
255
|
|
|
254
|
-
// Transfer: connected/held
|
|
256
|
+
// Transfer: connected/held/conference
|
|
255
257
|
transfer: (() => {
|
|
256
258
|
if (hasParallelConsultLeg) {
|
|
259
|
+
if (!customerPresent) return DISABLED;
|
|
257
260
|
if (state === TaskState.CONNECTED) return VISIBLE_ENABLED;
|
|
258
261
|
if (state === TaskState.HELD) return VISIBLE_DISABLED;
|
|
259
262
|
}
|
|
260
263
|
if (isConsulting) {
|
|
261
264
|
if (!consultInitiator) return DISABLED;
|
|
265
|
+
if (!customerPresent) return VISIBLE_DISABLED;
|
|
262
266
|
if (consultLegOnHold) return VISIBLE_DISABLED;
|
|
263
267
|
|
|
264
268
|
return isConsultDestinationReady ? VISIBLE_ENABLED : VISIBLE_DISABLED;
|
|
265
269
|
}
|
|
266
|
-
if (!hasFullControls
|
|
270
|
+
if (!hasFullControls) return DISABLED;
|
|
271
|
+
if (inConference) {
|
|
272
|
+
// Real conference (multiple agents): transfer is hidden
|
|
273
|
+
// Pending conference (only self agent): transfer remains available
|
|
274
|
+
return participantCount > 1 ? DISABLED : VISIBLE_ENABLED;
|
|
275
|
+
}
|
|
267
276
|
if (state === TaskState.CONNECTED || state === TaskState.HELD) return VISIBLE_ENABLED;
|
|
268
277
|
|
|
269
278
|
return DISABLED;
|
|
@@ -278,15 +287,22 @@ function computeVoiceInteractionUIControls(
|
|
|
278
287
|
return DISABLED;
|
|
279
288
|
}
|
|
280
289
|
|
|
281
|
-
//
|
|
290
|
+
// In conference: behavior depends on whether it's a real multi-agent conference
|
|
291
|
+
if (inConference) {
|
|
292
|
+
// Pending conference (only self agent): consult disabled
|
|
293
|
+
if (participantCount <= 1) return VISIBLE_DISABLED;
|
|
294
|
+
// Real conference: consult enabled if conditions met
|
|
295
|
+
const canFromConference =
|
|
296
|
+
!maxParticipants && customerPresent && !consultInProgress && !isConsulting;
|
|
297
|
+
|
|
298
|
+
return {isVisible: true, isEnabled: canFromConference};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Enabled conditions for connected/held
|
|
282
302
|
const canFromConnected =
|
|
283
303
|
!maxParticipants && customerPresent && !consultInProgress && !isConsulted;
|
|
284
|
-
const canFromConference =
|
|
285
|
-
!maxParticipants && customerPresent && !consultInProgress && !isConsulting;
|
|
286
304
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
return {isVisible: true, isEnabled};
|
|
305
|
+
return {isVisible: true, isEnabled: canFromConnected};
|
|
290
306
|
})(),
|
|
291
307
|
|
|
292
308
|
// ConsultTransfer: always hidden (use transfer button)
|
|
@@ -303,10 +319,16 @@ function computeVoiceInteractionUIControls(
|
|
|
303
319
|
return {isVisible: true, isEnabled: consultInitiator || config.isEndConsultEnabled};
|
|
304
320
|
})(),
|
|
305
321
|
|
|
306
|
-
// Recording: connected/held
|
|
322
|
+
// Recording: connected/held, hidden in real conference, visible in pending conference
|
|
307
323
|
recording: (() => {
|
|
308
324
|
if (!recordingControlsAvailable || !config.isRecordingEnabled) return DISABLED;
|
|
309
|
-
if (!hasFullControls || isConsulting
|
|
325
|
+
if (!hasFullControls || isConsulting) return DISABLED;
|
|
326
|
+
if (inConference) {
|
|
327
|
+
// Real conference (multiple agents): recording hidden
|
|
328
|
+
// Pending conference (only self agent): recording available
|
|
329
|
+
return participantCount > 1 ? DISABLED : VISIBLE_ENABLED;
|
|
330
|
+
}
|
|
331
|
+
if (hasParallelConsultLeg && !customerPresent) return DISABLED;
|
|
310
332
|
if (state === TaskState.CONNECTED || state === TaskState.HELD) {
|
|
311
333
|
return VISIBLE_ENABLED;
|
|
312
334
|
}
|
|
@@ -318,6 +340,7 @@ function computeVoiceInteractionUIControls(
|
|
|
318
340
|
// Label changes based on leg: "Conference" on main leg, "Merge" on consult leg
|
|
319
341
|
conference: (() => {
|
|
320
342
|
if (hasParallelConsultLeg) {
|
|
343
|
+
if (!customerPresent) return DISABLED;
|
|
321
344
|
if (state === TaskState.CONNECTED) {
|
|
322
345
|
return maxParticipants ? VISIBLE_DISABLED : VISIBLE_ENABLED;
|
|
323
346
|
}
|
|
@@ -327,6 +350,7 @@ function computeVoiceInteractionUIControls(
|
|
|
327
350
|
}
|
|
328
351
|
if (!hasFullControls || !isConsulting) return DISABLED;
|
|
329
352
|
if (!consultInitiator) return DISABLED;
|
|
353
|
+
if (!customerPresent) return VISIBLE_DISABLED;
|
|
330
354
|
if (consultLegOnHold) return VISIBLE_DISABLED;
|
|
331
355
|
|
|
332
356
|
return isConsultDestinationReady && !maxParticipants ? VISIBLE_ENABLED : VISIBLE_DISABLED;
|
|
@@ -335,10 +359,11 @@ function computeVoiceInteractionUIControls(
|
|
|
335
359
|
// Wrapup: wrapping up state
|
|
336
360
|
wrapup: isWrappingUp ? VISIBLE_ENABLED : DISABLED,
|
|
337
361
|
|
|
338
|
-
// ExitConference: in conference
|
|
362
|
+
// ExitConference: in conference with multiple agents in main call
|
|
339
363
|
exitConference: (() => {
|
|
340
364
|
if (isConsulted && !isConferencing) return DISABLED;
|
|
341
365
|
if (!inConference) return DISABLED;
|
|
366
|
+
if (participantCount <= 1) return DISABLED;
|
|
342
367
|
const consultingFromConference = consultInitiator && isConsulting && conferenceFromBackend;
|
|
343
368
|
|
|
344
369
|
return consultingFromConference ? VISIBLE_DISABLED : VISIBLE_ENABLED;
|
|
@@ -356,6 +381,7 @@ function computeVoiceInteractionUIControls(
|
|
|
356
381
|
// MergeToConference: mirrors conference control, enabled on both legs
|
|
357
382
|
mergeToConference: (() => {
|
|
358
383
|
if (!isConsulting || !consultInitiator) return DISABLED;
|
|
384
|
+
if (!customerPresent) return VISIBLE_DISABLED;
|
|
359
385
|
if (consultLegOnHold) return VISIBLE_DISABLED;
|
|
360
386
|
|
|
361
387
|
return isConsultDestinationReady && !maxParticipants ? VISIBLE_ENABLED : VISIBLE_DISABLED;
|
|
@@ -363,8 +389,10 @@ function computeVoiceInteractionUIControls(
|
|
|
363
389
|
|
|
364
390
|
// Switch: visible only on the currently active leg
|
|
365
391
|
switch: (() => {
|
|
392
|
+
if (!customerPresent && hasParallelConsultLeg) return DISABLED;
|
|
366
393
|
if (currentLeg === 'consult') {
|
|
367
394
|
if (!isConsulting || !consultInitiator || consultCallHeld) return DISABLED;
|
|
395
|
+
if (!customerPresent) return VISIBLE_DISABLED;
|
|
368
396
|
|
|
369
397
|
return isConsultDestinationReady ? VISIBLE_ENABLED : VISIBLE_DISABLED;
|
|
370
398
|
}
|
|
@@ -935,6 +935,8 @@ export type Interaction = {
|
|
|
935
935
|
fcDesktopView?: string;
|
|
936
936
|
/** Agent ID who initiated the outdial call */
|
|
937
937
|
outdialAgentId?: string;
|
|
938
|
+
/** Indicates if the customer has left the call during an active consult */
|
|
939
|
+
hasCustomerLeft?: string;
|
|
938
940
|
};
|
|
939
941
|
/** Main interaction identifier for related interactions */
|
|
940
942
|
mainInteractionId?: string;
|
|
@@ -25,7 +25,7 @@ import Task from '../Task';
|
|
|
25
25
|
import LoggerProxy from '../../../logger-proxy';
|
|
26
26
|
import MetricsManager from '../../../metrics/MetricsManager';
|
|
27
27
|
import {METRIC_EVENT_NAMES} from '../../../metrics/constants';
|
|
28
|
-
import {TaskState, TaskEvent} from '../state-machine';
|
|
28
|
+
import {TaskState, TaskEvent, TaskActionArgs} from '../state-machine';
|
|
29
29
|
import {WrapupData} from '../../config/types';
|
|
30
30
|
import {getConsultMediaResourceId, getIsConferenceInProgress} from '../TaskUtils';
|
|
31
31
|
|
|
@@ -710,17 +710,22 @@ export default class Voice extends Task implements IVoice {
|
|
|
710
710
|
? calculateDestType(this.data.interaction, this.data.agentId)
|
|
711
711
|
: '';
|
|
712
712
|
|
|
713
|
+
// derivedDestType is most reliable as it inspects live interaction participants
|
|
714
|
+
const resolvedDestinationType =
|
|
715
|
+
derivedDestType ||
|
|
716
|
+
this.getStateMachineSnapshot()?.context?.consultDestinationType ||
|
|
717
|
+
this.data.destinationType ||
|
|
718
|
+
'agent';
|
|
719
|
+
|
|
713
720
|
const consultationData: consultConferencePayloadData = {
|
|
714
721
|
agentId: this.data.agentId,
|
|
715
|
-
destinationType:
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
derivedDestType ||
|
|
719
|
-
'agent',
|
|
722
|
+
destinationType: resolvedDestinationType,
|
|
723
|
+
// derivedDestAgentId is most reliable as it resolves epId for EP_DN
|
|
724
|
+
// and agent ID for regular agents from live interaction data
|
|
720
725
|
destAgentId:
|
|
726
|
+
derivedDestAgentId ||
|
|
721
727
|
this.getStateMachineSnapshot()?.context?.consultDestinationAgentId ||
|
|
722
|
-
this.data.destAgentId
|
|
723
|
-
derivedDestAgentId,
|
|
728
|
+
this.data.destAgentId,
|
|
724
729
|
};
|
|
725
730
|
|
|
726
731
|
// Send state machine event to transition to CONF_INITIATING
|
|
@@ -1247,9 +1252,13 @@ export default class Voice extends Task implements IVoice {
|
|
|
1247
1252
|
TASK_EVENTS.TASK_CONFERENCE_TRANSFER_FAILED,
|
|
1248
1253
|
{updateTaskData: true}
|
|
1249
1254
|
),
|
|
1250
|
-
emitTaskOutdialFailed:
|
|
1251
|
-
|
|
1252
|
-
|
|
1255
|
+
emitTaskOutdialFailed: ({event}: TaskActionArgs) => {
|
|
1256
|
+
if (event && 'taskData' in event && event.taskData) {
|
|
1257
|
+
this.updateTaskData(event.taskData as TaskData);
|
|
1258
|
+
}
|
|
1259
|
+
const reason = (event as {reason?: string})?.reason || 'Outdial failed';
|
|
1260
|
+
this.emit(TASK_EVENTS.TASK_OUTDIAL_FAILED, reason);
|
|
1261
|
+
},
|
|
1253
1262
|
};
|
|
1254
1263
|
}
|
|
1255
1264
|
}
|
|
@@ -1280,6 +1280,32 @@ describe('TaskManager', () => {
|
|
|
1280
1280
|
sendStateMachineEventSpy.mockRestore();
|
|
1281
1281
|
});
|
|
1282
1282
|
|
|
1283
|
+
it('should pass taskData in OUTBOUND_FAILED event for shouldWrapUp guard evaluation', () => {
|
|
1284
|
+
const task = taskManager.getTask(taskId);
|
|
1285
|
+
const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
|
|
1286
|
+
const payload = {
|
|
1287
|
+
data: {
|
|
1288
|
+
type: CC_EVENTS.AGENT_OUTBOUND_FAILED,
|
|
1289
|
+
interactionId: taskId,
|
|
1290
|
+
reason: 'CUSTOMER_BUSY',
|
|
1291
|
+
agentsPendingWrapUp: ['agent-123'],
|
|
1292
|
+
interaction: {
|
|
1293
|
+
outboundType: 'OUTDIAL',
|
|
1294
|
+
isTerminated: true,
|
|
1295
|
+
},
|
|
1296
|
+
},
|
|
1297
|
+
};
|
|
1298
|
+
webSocketManagerMock.emit('message', JSON.stringify(payload));
|
|
1299
|
+
const stateMachineEvent = expectLastStateMachineEvent(
|
|
1300
|
+
sendStateMachineEventSpy,
|
|
1301
|
+
TaskEvent.OUTBOUND_FAILED
|
|
1302
|
+
);
|
|
1303
|
+
expect(stateMachineEvent?.taskData).toBeDefined();
|
|
1304
|
+
expect(stateMachineEvent?.taskData?.agentsPendingWrapUp).toEqual(['agent-123']);
|
|
1305
|
+
expect(stateMachineEvent?.taskData?.interaction?.outboundType).toBe('OUTDIAL');
|
|
1306
|
+
sendStateMachineEventSpy.mockRestore();
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1283
1309
|
it('should handle AGENT_OUTBOUND_FAILED gracefully when task is undefined', () => {
|
|
1284
1310
|
const payload = {
|
|
1285
1311
|
data: {
|