Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
c3a465b
fix(multy-party-conference): implement-multy-party-conference-feature
akulakum Oct 7, 2025
356676b
Merge branch 'next' into MULTI_PARTY_CONFERENCE
akulakum Oct 7, 2025
7139c62
fix: fix user defined properties not to delete
rsarika Oct 8, 2025
e280d93
fix: updated types
rsarika Oct 9, 2025
9069beb
fix: updated task utils to function approach
rsarika Oct 9, 2025
740e08a
fix: desktop mode not working
rsarika Oct 9, 2025
943d728
fix(multy-party-conference): implement-sample-appjs-changes
akulakum Oct 9, 2025
99dbcf1
Merge branch 'next' into MULTI_PARTY_CONFERENCE
akulakum Oct 10, 2025
dda2829
Merge branch 'next' into MULTI_PARTY_CONFERENCE
akulakum Oct 13, 2025
98282e2
fix(multy-party-conference): fix-taskUpdate-issue
akulakum Oct 13, 2025
fcc7a2a
feat(conference): enhance task handling and UI updates for consultations
rsarika Oct 13, 2025
7301e9b
fix: added await for login
rsarika Oct 13, 2025
aebded3
fix: removed ravi
rsarika Oct 13, 2025
80ddc86
feat(call-control): streamline UI updates and control management for …
rsarika Oct 13, 2025
8135ed8
fix: conference transfer
rsarika Oct 13, 2025
329023c
fix: cleanup
rsarika Oct 13, 2025
ce038aa
fix: mute element fix for extension
rsarika Oct 14, 2025
4c4998a
Merge branch 'next' of github.com:webex/webex-js-sdk into MULTI_PARTY…
rsarika Oct 14, 2025
941021f
fix: cleanup of isConferenceInProgress
rsarika Oct 14, 2025
3122eb9
Merge branch 'next' into MULTI_PARTY_CONFERENCE
akulakum Oct 14, 2025
6f6a452
fix: removed unused function
rsarika Oct 14, 2025
be7b701
fix: double webex initialization
rsarika Oct 14, 2025
b101dec
Merge branch 'next' into MULTI_PARTY_CONFERENCE
akulakum Oct 15, 2025
6ea4f54
fix(multy-party-conference): address-review-comments
akulakum Oct 15, 2025
2b1f198
Merge branch 'next' into MULTI_PARTY_CONFERENCE
akulakum Oct 16, 2025
23ff75f
fix(multy-party-conference): address-review-comments
akulakum Oct 16, 2025
f37fb39
Merge branch 'next' into MULTI_PARTY_CONFERENCE
akulakum Oct 17, 2025
5a249f7
fix(multy-party-conference): address-review-comments
akulakum Oct 17, 2025
40bd1c5
Merge branch 'next' into MULTI_PARTY_CONFERENCE
akulakum Oct 17, 2025
9370463
Merge branch 'next' into MULTI_PARTY_CONFERENCE
akulakum Oct 17, 2025
5608057
Merge branch 'next' into MULTI_PARTY_CONFERENCE
akulakum Oct 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
660 changes: 410 additions & 250 deletions docs/samples/contact-center/app.js

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions docs/samples/contact-center/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,16 @@ <h2 class="collapsible">
<button onclick="answer()" disabled="" id="answer" class="btn--green">Answer</button>
<button onclick="decline()" disabled="" id="decline" class="btn--red">Decline</button>
</div>
<div id="participant-list"></div>

<!-- MPC: Allow participants to interact checkbox for testing -->
<div id="allow-interact" style="margin-top:10px;padding:8px;border:1px solid #ddd;background:#f0f8ff;display:none;">
<label>
<input type="checkbox" id="allow-participants-interact" checked>
🔄 Allow participants to continue interacting with each other
</label>
<br/><small style="color:#666;">📝 MPC Testing: Simulates the consultation interaction behavior</small>
</div>
</fieldset>
<fieldset>
<legend>Remote Audio</legend>
Expand Down
2 changes: 1 addition & 1 deletion packages/@webex/contact-center/src/cc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -784,7 +784,7 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter
throw error;
}

const loginResponse = this.services.agent.stationLogin({
const loginResponse = await this.services.agent.stationLogin({
data: {
dialNumber:
data.loginOption === LoginOption.BROWSER ? this.agentConfig.agentId : data.dialNumber,
Expand Down
56 changes: 49 additions & 7 deletions packages/@webex/contact-center/src/services/task/TaskManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ import LoggerProxy from '../../logger-proxy';
import Task from '.';
import MetricsManager from '../../metrics/MetricsManager';
import {METRIC_EVENT_NAMES} from '../../metrics/constants';
import {
checkParticipantNotInInteraction,
getIsConferenceInProgress,
isParticipantInMainInteraction,
isPrimary,
} from './TaskUtils';

/** @internal */
export default class TaskManager extends EventEmitter {
Expand Down Expand Up @@ -128,8 +134,7 @@ export default class TaskManager extends EventEmitter {
{
...payload.data,
wrapUpRequired:
payload.data.interaction?.participants?.[payload.data.agentId]?.isWrapUp ||
false,
payload.data.interaction?.participants?.[this.agentId]?.isWrapUp || false,
},
this.wrapupData,
this.agentId
Expand Down Expand Up @@ -358,18 +363,55 @@ export default class TaskManager extends EventEmitter {
case CC_EVENTS.AGENT_CONSULT_CONFERENCE_ENDED:
// Conference ended - update task state and emit event
task = this.updateTaskData(task, payload.data);
task.emit(TASK_EVENTS.TASK_CONFERENCE_ENDED, task);
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:
case CC_EVENTS.PARTICIPANT_JOINED_CONFERENCE: {
// Participant joined conference - update task state with participant information and emit event
task = this.updateTaskData(task, payload.data);
// Pre-calculate isConferenceInProgress with updated data to avoid double update
const simulatedTaskForJoin = {
...task,
data: {...task.data, ...payload.data},
};
task = this.updateTaskData(task, {
...payload.data,
isConferenceInProgress: getIsConferenceInProgress(simulatedTaskForJoin),
});
task.emit(TASK_EVENTS.TASK_PARTICIPANT_JOINED, task);
break;
case CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE:
}
case CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE: {
// Conference ended - update task state and emit event
task = this.updateTaskData(task, payload.data);
// Pre-calculate isConferenceInProgress with updated data to avoid double update
const simulatedTaskForLeft = {
...task,
data: {...task.data, ...payload.data},
};
task = this.updateTaskData(task, {
...payload.data,
isConferenceInProgress: getIsConferenceInProgress(simulatedTaskForLeft),
});
if (checkParticipantNotInInteraction(task, this.agentId)) {
if (
isParticipantInMainInteraction(task, this.agentId) ||
Comment on lines +402 to +404
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aren't these functions negation of each other?

If checkParticipantNotInInteraction is true, isParticipantInMainInteraction should always be false

Is my understanding correct here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first one is to check whether it is an interaction or not. The second one is whether it is in main interaction(mediaObj.mType === 'mainCall')

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly.

So, if the first one says that the participant is not in any interaction, they are definitely not in the main interaction. Right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a small difference of condition hasLeft in first method. This logic also we copied from Agent desktop.

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
task = this.updateTaskData(task, payload.data);
Expand Down
94 changes: 94 additions & 0 deletions packages/@webex/contact-center/src/services/task/TaskUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/* eslint-disable import/prefer-default-export */
import {ITask} from './types';

/**
* Determines if the given agent is the primary agent (owner) of the task
* @param task - The task to check
* @param agentId - The agent ID to check for primary status
* @returns true if the agent is the primary agent, false otherwise
*/
export const isPrimary = (task: ITask, agentId: string): boolean => {
if (!task?.data?.interaction?.owner) {
// Fall back to checking data.agentId when owner is not set
return task?.data?.agentId === agentId;
}

return task.data.interaction.owner === agentId;
};

/**
* Checks if the given agent is a participant in the main interaction (mainCall)
* @param task - The task to check
* @param agentId - The agent ID to check for participation
* @returns true if the agent is a participant in the main interaction, false otherwise
*/
export const isParticipantInMainInteraction = (task: ITask, agentId: string): boolean => {
if (!task?.data?.interaction?.media) {
return false;
}

return Object.values(task.data.interaction.media).some(
(mediaObj: any) => mediaObj.mType === 'mainCall' && mediaObj.participants?.includes(agentId)
);
};

/**
* Checks if the given agent is not in the interaction or has left the interaction
* @param task - The task to check
* @param agentId - The agent ID to check
* @returns true if the agent is not in the interaction or has left, false otherwise
*/
export const checkParticipantNotInInteraction = (task: ITask, agentId: string): boolean => {
if (!task?.data?.interaction?.participants) {
return true;
}
const {data} = task;

return (
!(agentId in data.interaction.participants) ||
(agentId in data.interaction.participants && data.interaction.participants[agentId].hasLeft)
);
};

/**
* Gets the participant status for a given agent in a task
* @param task - The task to check
* @param agentId - The agent ID to get status for
* @returns Object containing various status flags for the agent
*/
export const getParticipantStatus = (task: ITask, agentId: string) => ({
isPrimary: isPrimary(task, agentId),
isInMainInteraction: isParticipantInMainInteraction(task, agentId),
isNotInInteraction: checkParticipantNotInInteraction(task, agentId),
isOwner: task?.data?.interaction?.owner === agentId,
hasLeft: task?.data?.interaction?.participants?.[agentId]?.hasLeft || false,
});

/**
* Determines if a conference is currently in progress based on the number of active agent participants
* @param task - The task to check for conference status
* @returns true if there are 2 or more active agent participants in the main call, false otherwise
*/
export const getIsConferenceInProgress = (task: ITask): boolean => {
const mediaMainCall = task?.data?.interaction?.media?.[task?.data?.interactionId];
const participantsInMainCall = new Set(mediaMainCall?.participants);
const participants = task?.data?.interaction?.participants;

const agentParticipants = new Set();
if (participantsInMainCall.size > 0) {
participantsInMainCall.forEach((participantId: string) => {
const participant = participants?.[participantId];
if (
participant &&
participant.pType !== 'Customer' &&
participant.pType !== 'Supervisor' &&
!participant.hasLeft &&
participant.pType !== 'VVA'
) {
agentParticipants.add(participantId);
}
});
}

return agentParticipants.size >= 2;
};
19 changes: 19 additions & 0 deletions packages/@webex/contact-center/src/services/task/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,25 @@ export const CONFERENCE_TRANSFER = '/conference/transfer';
export const TASK_MANAGER_FILE = 'taskManager';
export const TASK_FILE = 'task';

/**
* Task data field names that should be preserved during reconciliation
* These fields are retained even if not present in new data during updates
*/
export const PRESERVED_TASK_DATA_FIELDS = {
/** Indicates if the task is in consultation state */
IS_CONSULTED: 'isConsulted',
/** Indicates if wrap-up is required for this task */
WRAP_UP_REQUIRED: 'wrapUpRequired',
/** Indicates if a conference is currently in progress (2+ active agents) */
IS_CONFERENCE_IN_PROGRESS: 'isConferenceInProgress',
};

/**
* Array of task data field names that should not be deleted during reconciliation
* Used by reconcileData method to preserve important task state fields
*/
export const KEYS_TO_NOT_DELETE: string[] = Object.values(PRESERVED_TASK_DATA_FIELDS);

// METHOD NAMES
export const METHODS = {
// Task class methods
Expand Down
28 changes: 20 additions & 8 deletions packages/@webex/contact-center/src/services/task/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
import {Failure} from '../core/GlobalTypes';
import {LoginOption} from '../../types';
import {TASK_FILE} from '../../constants';
import {METHODS} from './constants';
import {METHODS, KEYS_TO_NOT_DELETE} from './constants';
import routingContact from './contact';
import LoggerProxy from '../../logger-proxy';
import {
Expand Down Expand Up @@ -281,9 +281,24 @@ export default class Task extends EventEmitter implements ITask {
* @private
*/
private reconcileData(oldData: TaskData, newData: TaskData): TaskData {
// Remove keys from oldData that are not in newData
Object.keys(oldData).forEach((key) => {
if (!(key in newData) && !KEYS_TO_NOT_DELETE.includes(key as string)) {
delete oldData[key];
}
});

// Merge or update keys from newData
Object.keys(newData).forEach((key) => {
if (newData[key] && typeof newData[key] === 'object' && !Array.isArray(newData[key])) {
oldData[key] = this.reconcileData({...oldData[key]}, newData[key]);
if (
newData[key] &&
typeof newData[key] === 'object' &&
!Array.isArray(newData[key]) &&
oldData[key] &&
typeof oldData[key] === 'object' &&
!Array.isArray(oldData[key])
) {
this.reconcileData(oldData[key], newData[key]);
} else {
oldData[key] = newData[key];
}
Expand Down Expand Up @@ -1684,9 +1699,6 @@ export default class Task extends EventEmitter implements ITask {
}
}

// TODO: Uncomment this method in future PR for Multi-Party Conference support (>3 participants)
// Conference transfer will be supported when implementing enhanced multi-party conference functionality
/*
/**
* Transfers the current conference to another agent
*
Expand All @@ -1707,7 +1719,7 @@ export default class Task extends EventEmitter implements ITask {
* }
* ```
*/
/* public async transferConference(): Promise<TaskResponse> {
public async transferConference(): Promise<TaskResponse> {
try {
LoggerProxy.info(`Transferring conference`, {
module: TASK_FILE,
Expand Down Expand Up @@ -1771,5 +1783,5 @@ export default class Task extends EventEmitter implements ITask {

throw err;
}
} */
}
}
Loading
Loading