@webex/contact-center 3.12.0-task-refactor.4 → 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 +1 -0
- package/dist/services/task/TaskManager.js.map +1 -1
- package/dist/services/task/state-machine/TaskStateMachine.js +6 -1
- package/dist/services/task/state-machine/TaskStateMachine.js.map +1 -1
- package/dist/services/task/state-machine/uiControlsComputer.js +7 -3
- package/dist/services/task/state-machine/uiControlsComputer.js.map +1 -1
- package/dist/services/task/voice/Voice.js +9 -3
- package/dist/services/task/voice/Voice.js.map +1 -1
- package/dist/types/services/task/state-machine/TaskStateMachine.d.ts +8 -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 +1 -1
- package/src/services/task/state-machine/TaskStateMachine.ts +7 -1
- package/src/services/task/state-machine/uiControlsComputer.ts +8 -6
- package/src/services/task/voice/Voice.ts +8 -4
- package/test/unit/spec/services/task/TaskManager.ts +26 -0
- package/test/unit/spec/services/task/state-machine/TaskStateMachine.ts +61 -0
- package/test/unit/spec/services/task/state-machine/uiControlsComputer.ts +48 -0
- 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
|
@@ -50,6 +50,10 @@ export declare function getTaskStateMachineConfig(uiControlConfig: UIControlConf
|
|
|
50
50
|
target: TaskState;
|
|
51
51
|
actions: string[];
|
|
52
52
|
};
|
|
53
|
+
OUTBOUND_FAILED: {
|
|
54
|
+
target: TaskState;
|
|
55
|
+
actions: string[];
|
|
56
|
+
};
|
|
53
57
|
CONSULTING_ACTIVE: {
|
|
54
58
|
target: TaskState;
|
|
55
59
|
actions: string[];
|
|
@@ -522,6 +526,10 @@ export declare function createTaskStateMachine(uiControlConfig: UIControlConfig,
|
|
|
522
526
|
target: TaskState;
|
|
523
527
|
actions: string[];
|
|
524
528
|
};
|
|
529
|
+
OUTBOUND_FAILED: {
|
|
530
|
+
target: TaskState;
|
|
531
|
+
actions: string[];
|
|
532
|
+
};
|
|
525
533
|
CONSULTING_ACTIVE: {
|
|
526
534
|
target: TaskState;
|
|
527
535
|
actions: string[];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import routingContact from '../contact';
|
|
2
2
|
import { ConsultPayload, ConsultEndPayload, ResumeRecordingPayload, TaskData, TaskResponse, IVoice, VoiceUIControlOptions, TransferPayLoad } from '../types';
|
|
3
3
|
import Task from '../Task';
|
|
4
|
+
import { TaskActionArgs } from '../state-machine';
|
|
4
5
|
import { WrapupData } from '../../config/types';
|
|
5
6
|
export default class Voice extends Task implements IVoice {
|
|
6
7
|
constructor(contact: ReturnType<typeof routingContact>, data: TaskData, callOptions?: VoiceUIControlOptions, wrapupData?: WrapupData, agentId?: string);
|
|
@@ -162,22 +163,22 @@ export default class Voice extends Task implements IVoice {
|
|
|
162
163
|
*/
|
|
163
164
|
switchCall(): Promise<TaskResponse>;
|
|
164
165
|
protected getChannelSpecificActionOverrides(): {
|
|
165
|
-
emitTaskHold: ({ event }:
|
|
166
|
-
emitTaskResume: ({ event }:
|
|
167
|
-
emitTaskRecordingStarted: ({ event }:
|
|
168
|
-
emitTaskRecordingPaused: ({ event }:
|
|
169
|
-
emitTaskRecordingPauseFailed: ({ event }:
|
|
170
|
-
emitTaskRecordingResumed: ({ event }:
|
|
171
|
-
emitTaskRecordingResumeFailed: ({ event }:
|
|
172
|
-
emitTaskParticipantJoined: ({ event }:
|
|
173
|
-
emitTaskParticipantLeft: ({ event }:
|
|
174
|
-
emitTaskConferenceStarted: ({ event }:
|
|
175
|
-
emitTaskConferenceEnded: ({ event }:
|
|
176
|
-
emitTaskConferenceFailed: ({ event }:
|
|
177
|
-
emitTaskExitConference: ({ event }:
|
|
178
|
-
emitTaskTransferConference: ({ event }:
|
|
179
|
-
emitTaskSwitchCall: ({ event }:
|
|
180
|
-
emitTaskTransferConferenceFailed: ({ event }:
|
|
181
|
-
emitTaskOutdialFailed: ({ event }:
|
|
166
|
+
emitTaskHold: ({ event }: TaskActionArgs) => void;
|
|
167
|
+
emitTaskResume: ({ event }: TaskActionArgs) => void;
|
|
168
|
+
emitTaskRecordingStarted: ({ event }: TaskActionArgs) => void;
|
|
169
|
+
emitTaskRecordingPaused: ({ event }: TaskActionArgs) => void;
|
|
170
|
+
emitTaskRecordingPauseFailed: ({ event }: TaskActionArgs) => void;
|
|
171
|
+
emitTaskRecordingResumed: ({ event }: TaskActionArgs) => void;
|
|
172
|
+
emitTaskRecordingResumeFailed: ({ event }: TaskActionArgs) => void;
|
|
173
|
+
emitTaskParticipantJoined: ({ event }: TaskActionArgs) => void;
|
|
174
|
+
emitTaskParticipantLeft: ({ event }: TaskActionArgs) => void;
|
|
175
|
+
emitTaskConferenceStarted: ({ event }: TaskActionArgs) => void;
|
|
176
|
+
emitTaskConferenceEnded: ({ event }: TaskActionArgs) => void;
|
|
177
|
+
emitTaskConferenceFailed: ({ event }: TaskActionArgs) => void;
|
|
178
|
+
emitTaskExitConference: ({ event }: TaskActionArgs) => void;
|
|
179
|
+
emitTaskTransferConference: ({ event }: TaskActionArgs) => void;
|
|
180
|
+
emitTaskSwitchCall: ({ event }: TaskActionArgs) => void;
|
|
181
|
+
emitTaskTransferConferenceFailed: ({ event }: TaskActionArgs) => void;
|
|
182
|
+
emitTaskOutdialFailed: ({ event }: TaskActionArgs) => void;
|
|
182
183
|
};
|
|
183
184
|
}
|
package/dist/webex.js
CHANGED
package/package.json
CHANGED
|
@@ -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};
|
|
@@ -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
|
|
@@ -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: (() => {
|
|
@@ -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
|
|
|
@@ -1252,9 +1252,13 @@ export default class Voice extends Task implements IVoice {
|
|
|
1252
1252
|
TASK_EVENTS.TASK_CONFERENCE_TRANSFER_FAILED,
|
|
1253
1253
|
{updateTaskData: true}
|
|
1254
1254
|
),
|
|
1255
|
-
emitTaskOutdialFailed:
|
|
1256
|
-
|
|
1257
|
-
|
|
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
|
+
},
|
|
1258
1262
|
};
|
|
1259
1263
|
}
|
|
1260
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: {
|
|
@@ -573,4 +573,65 @@ describe('Task state machine', () => {
|
|
|
573
573
|
expect(service.getSnapshot().value).toBe(TaskState.TERMINATED);
|
|
574
574
|
});
|
|
575
575
|
});
|
|
576
|
+
|
|
577
|
+
describe('OUTBOUND_FAILED handling', () => {
|
|
578
|
+
it('transitions from IDLE to TERMINATED on OUTBOUND_FAILED (race condition)', () => {
|
|
579
|
+
const service = startMachine();
|
|
580
|
+
expect(service.getSnapshot().value).toBe(TaskState.IDLE);
|
|
581
|
+
|
|
582
|
+
const taskData = createTaskData({
|
|
583
|
+
interaction: {
|
|
584
|
+
outboundType: 'OUTDIAL',
|
|
585
|
+
isTerminated: true,
|
|
586
|
+
} as any,
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
service.send({type: TaskEvent.OUTBOUND_FAILED, taskData, reason: 'CUSTOMER_BUSY'});
|
|
590
|
+
expect(service.getSnapshot().value).toBe(TaskState.TERMINATED);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it('transitions from OFFERED to TERMINATED on OUTBOUND_FAILED without wrapup', () => {
|
|
594
|
+
const service = startMachine();
|
|
595
|
+
const offerTaskData = createTaskData({
|
|
596
|
+
interaction: {
|
|
597
|
+
outboundType: 'OUTDIAL',
|
|
598
|
+
} as any,
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
service.send({type: TaskEvent.TASK_INCOMING, taskData: offerTaskData});
|
|
602
|
+
expect(service.getSnapshot().value).toBe(TaskState.OFFERED);
|
|
603
|
+
|
|
604
|
+
const failedTaskData = createTaskData({
|
|
605
|
+
interaction: {
|
|
606
|
+
outboundType: 'OUTDIAL',
|
|
607
|
+
isTerminated: true,
|
|
608
|
+
} as any,
|
|
609
|
+
});
|
|
610
|
+
service.send({type: TaskEvent.OUTBOUND_FAILED, taskData: failedTaskData, reason: 'CUSTOMER_BUSY'});
|
|
611
|
+
expect(service.getSnapshot().value).toBe(TaskState.TERMINATED);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it('transitions from OFFERED to WRAPPING_UP on OUTBOUND_FAILED when wrapup is required', () => {
|
|
615
|
+
const service = startMachine();
|
|
616
|
+
const offerTaskData = createTaskData({
|
|
617
|
+
interaction: {
|
|
618
|
+
outboundType: 'OUTDIAL',
|
|
619
|
+
} as any,
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
service.send({type: TaskEvent.TASK_INCOMING, taskData: offerTaskData});
|
|
623
|
+
expect(service.getSnapshot().value).toBe(TaskState.OFFERED);
|
|
624
|
+
|
|
625
|
+
const failedTaskData = createTaskData({
|
|
626
|
+
agentId: 'agent-1',
|
|
627
|
+
agentsPendingWrapUp: ['agent-1'],
|
|
628
|
+
interaction: {
|
|
629
|
+
outboundType: 'OUTDIAL',
|
|
630
|
+
isTerminated: true,
|
|
631
|
+
} as any,
|
|
632
|
+
});
|
|
633
|
+
service.send({type: TaskEvent.OUTBOUND_FAILED, taskData: failedTaskData, reason: 'CUSTOMER_BUSY'});
|
|
634
|
+
expect(service.getSnapshot().value).toBe(TaskState.WRAPPING_UP);
|
|
635
|
+
});
|
|
636
|
+
});
|
|
576
637
|
});
|
|
@@ -146,6 +146,54 @@ describe('uiControlsComputer consult initiator controls', () => {
|
|
|
146
146
|
});
|
|
147
147
|
});
|
|
148
148
|
|
|
149
|
+
describe('uiControlsComputer outdial accept/decline controls', () => {
|
|
150
|
+
function createOutdialContext(voiceVariant: 'webrtc' | 'pstn' = 'webrtc'): TaskContext {
|
|
151
|
+
const taskData = createTaskData({
|
|
152
|
+
interaction: {
|
|
153
|
+
outboundType: 'OUTDIAL',
|
|
154
|
+
state: 'new',
|
|
155
|
+
isTerminated: false,
|
|
156
|
+
} as any,
|
|
157
|
+
});
|
|
158
|
+
return {
|
|
159
|
+
taskData,
|
|
160
|
+
consultInitiator: false,
|
|
161
|
+
exitingConference: false,
|
|
162
|
+
consultFromConference: false,
|
|
163
|
+
transferConferenceRequested: false,
|
|
164
|
+
consultDestinationType: null,
|
|
165
|
+
consultDestinationAgentId: null,
|
|
166
|
+
consultDestinationAgentJoined: false,
|
|
167
|
+
consultCallHeld: false,
|
|
168
|
+
recordingControlsAvailable: false,
|
|
169
|
+
recordingInProgress: false,
|
|
170
|
+
uiControlConfig: {
|
|
171
|
+
isEndTaskEnabled: true,
|
|
172
|
+
isEndConsultEnabled: true,
|
|
173
|
+
channelType: TASK_CHANNEL_TYPE.VOICE,
|
|
174
|
+
isRecordingEnabled: false,
|
|
175
|
+
agentId: 'agent-1',
|
|
176
|
+
voiceVariant,
|
|
177
|
+
},
|
|
178
|
+
uiControls: getDefaultUIControls(),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
it('accept is visible but disabled for WebRTC outdial in OFFERED state', () => {
|
|
183
|
+
const context = createOutdialContext('webrtc');
|
|
184
|
+
const uiControls = computeUIControls(TaskState.OFFERED, context, context.taskData);
|
|
185
|
+
|
|
186
|
+
expect(uiControls.main.accept).toEqual({isVisible: true, isEnabled: false});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('decline is visible but disabled for WebRTC outdial in OFFERED state', () => {
|
|
190
|
+
const context = createOutdialContext('webrtc');
|
|
191
|
+
const uiControls = computeUIControls(TaskState.OFFERED, context, context.taskData);
|
|
192
|
+
|
|
193
|
+
expect(uiControls.main.decline).toEqual({isVisible: true, isEnabled: false});
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
149
197
|
describe('uiControlsComputer conference controls', () => {
|
|
150
198
|
function createConferenceTaskData(participantCount: number) {
|
|
151
199
|
const participants: Record<string, any> = {
|
|
@@ -72,6 +72,30 @@ describe('Voice Task', () => {
|
|
|
72
72
|
jest.clearAllMocks();
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
+
describe('emitTaskOutdialFailed', () => {
|
|
76
|
+
it('emits the failure reason string instead of the Task object', () => {
|
|
77
|
+
const taskData = createBaseData({
|
|
78
|
+
interaction: {
|
|
79
|
+
outboundType: 'OUTDIAL',
|
|
80
|
+
} as any,
|
|
81
|
+
});
|
|
82
|
+
const voice = new Voice(dummyContact, taskData, {});
|
|
83
|
+
const emitSpy = jest.spyOn(voice, 'emit');
|
|
84
|
+
|
|
85
|
+
voice.sendStateMachineEvent({
|
|
86
|
+
type: TaskEvent.OUTBOUND_FAILED,
|
|
87
|
+
taskData,
|
|
88
|
+
reason: 'CUSTOMER_BUSY',
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const outdialFailedCall = emitSpy.mock.calls.find(
|
|
92
|
+
(call) => call[0] === 'task:outdialFailed'
|
|
93
|
+
);
|
|
94
|
+
expect(outdialFailedCall).toBeDefined();
|
|
95
|
+
expect(outdialFailedCall![1]).toBe('CUSTOMER_BUSY');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
75
99
|
it('hides end and endConsult when disabled', () => {
|
|
76
100
|
const voice = new Voice(dummyContact, createBaseData(), {
|
|
77
101
|
isEndTaskEnabled: false,
|