Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New feature 'sip mute' for muting worker non-webrtc calls #342

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 27 additions & 0 deletions docs/docs/feature-library/sip-mute.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
sidebar_label: sip-mute
title: sip-mute
---

When on a SIP call the default Flex Mute button will not mute the Flex Worker. This feature adds a mute button for non-WebRTC calls (i.e. Calls via SIP or PSTN).

The default mute button requires the Voice SDK to be used as it calls the local method to mute, this plugin introduces a replacement UI button with a companion serverless function to mute the Flex worker by modifying the conference participants

This feature is based on [Flex 1.0 station selector](https://github.com/jlafer/plugin-station-selector/tree/master/src).

# flex-user-experience

![Mute demo](/img/features/sip-mute/demo.gif)

# setup and dependencies

## flex-config

Within your `ui_attributes` file, the `sip-mute` feature has 3 settings you may modify:

- `true` - whether any functionality from this feature is enabled
- `false` - default flex behaviour


# how does it work?
This plugin calls the Conference Participants API via a serverless function (in this project). Passing through the `muted` state
Binary file added docs/static/img/features/sip-mute/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 9 additions & 6 deletions flex-config/ui_attributes.common.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,23 @@
"activity_skill_filter": {
"enabled": true,
"rules": {
"On a Task" : {
"On a Task": {
"required_skill": "system_activities",
"sort_order": 0
},
"On a Task, No ACD" : {
"On a Task, No ACD": {
"required_skill": "system_activities",
"sort_order": 0
},
"Wrap Up" : {
"Wrap Up": {
"required_skill": "system_activities",
"sort_order": 0
},
"Wrap Up, No ACD" : {
"Wrap Up, No ACD": {
"required_skill": "system_activities",
"sort_order": 0
},
"Offline" : {
"Offline": {
"required_skill": null,
"sort_order": 100
}
Expand Down Expand Up @@ -263,7 +263,10 @@
"location": false,
"agent_skills": true
}
},
"sip_mute": {
"enabled": false
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { getFeatureFlags } from '../../utils/configuration';
import SipMuteConfig from './types/ServiceConfiguration';

const { enabled = false } = (getFeatureFlags()?.features?.sip_mute as SipMuteConfig) || {};

export const isFeatureEnabled = () => {
return enabled;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { IconButton, TaskHelper, ITask, templates } from '@twilio/flex-ui';
import { StringTemplates } from '../flex-hooks/strings/Mute';
import { getLocalParticipantForTask } from '../helpers/CallControlHelper';
import CallControlService from '../helpers/CallControlService';
import { useEffect, useState } from 'react';
import * as Flex from '@twilio/flex-ui';

export interface OwnProps {
task?: ITask;
}
const CustomMuteButton = (props: OwnProps) => {
const isLiveCall = props.task ? TaskHelper.isLiveCall(props.task) : false;
const [muted, setMuted] = useState(false);
const [pending, setPending] = useState(false);

useEffect(() => {
if (!props.task?.conference) return;
const workerParticipant = props.task.conference.participants.find((p) => p.isCurrentWorker);

if (workerParticipant) {
setMuted(workerParticipant.muted);
}

setPending(false);
}, [props.task?.conference, props.task?.conference?.participants]);

const handleClick = async () => {
if (!props.task) {
console.error(`No task active`, props.task);
return;
}

const conferenceSid = props.task?.conference?.conferenceSid || props.task?.attributes?.conference?.sid;
if (!conferenceSid) {
console.error(`No Conference SID`, props.task);
return;
}

const participantCallSid = getLocalParticipantForTask(props.task);
if (!participantCallSid) {
console.error(`No Participant`, props.task);
return;
}

setPending(true);
// Note that it may seem redundant to set the mute state here as well as above
// however it is set here to support the scenario where Flex UI has been refreshed
// during an active call, which means there will be no state in Redux
// This should not occur generally but if it does this will resolve the UI mute state
if (muted) {
CallControlService.unmuteParticipant(conferenceSid, participantCallSid)
.then(() => setMuted(false))
.finally(() => setPending(false));
} else {
CallControlService.muteParticipant(conferenceSid, participantCallSid)
.then(() => setMuted(true))
.finally(() => setPending(false));
}
};

return (
<>
<IconButton
icon={muted ? 'MuteLargeBold' : 'MuteLarge'}
disabled={!isLiveCall || pending}
onClick={handleClick}
variant="secondary"
title={templates[StringTemplates.MuteParticipant]()}
chaosloth marked this conversation as resolved.
Show resolved Hide resolved
/>
</>
);
};

export default CustomMuteButton;
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import * as Flex from '@twilio/flex-ui';

import CustomMuteButton from '../../custom-components/CustomMuteButton';
import { FlexComponent } from '../../../../types/feature-loader';
import { isWorkerUsingWebRTC } from '../../helpers/CallControlHelper';

export const componentName = FlexComponent.MessageInputActions;
export const componentHook = function addMuteButton(flex: typeof Flex, _manager: Flex.Manager) {
const shouldModifyMuteButton = () => {
return !isWorkerUsingWebRTC();
};

flex.CallCanvasActions.Content.remove('toggleMute', { if: shouldModifyMuteButton });

flex.CallCanvasActions.Content.add(<CustomMuteButton key="custom-mute-button" />, {
sortOrder: -1,
if: shouldModifyMuteButton,
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Export the template names as an enum for better maintainability when accessing them elsewhere
export enum StringTemplates {
MuteParticipant = 'PSMuteParticipant',
}

export const stringHook = () => ({
'en-US': {
[StringTemplates.MuteParticipant]: 'Mute Participant',
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { ITask, Manager } from '@twilio/flex-ui';

const manager = Manager.getInstance();

export const isWorkerUsingWebRTC = (): boolean => {
return manager.workerClient?.attributes?.contact_uri.startsWith('client:');
chaosloth marked this conversation as resolved.
Show resolved Hide resolved
};

export const getLocalParticipantForTask = (task: ITask) => {
return task.attributes?.conference?.participants?.worker;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/* Created for use with sip-mute feature

Based on the amazing work done by John Lafer
https://github.com/jlafer/plugin-station-selector

Ported to PS Plugin by Chris Connolly
*/

import ApiService from '../../../utils/serverless/ApiService';
import { EncodedParams } from '../../../types/serverless';
import { FetchedCall, FetchedConferenceParticipant } from '../../../types/serverless/twilio-api';

export interface GetCallResponse {
success: boolean;
callProperties: FetchedCall;
}

export interface ParticipantResponse {
success: boolean;
participantsResponse: FetchedConferenceParticipant;
}

export interface RemoveParticipantResponse {
success: boolean;
}

class CallControlService extends ApiService {
_toggleMuteParticipant = async (conferenceSid: string, participantSid: string, muted: boolean): Promise<string> => {
return new Promise((resolve, reject) => {
const encodedParams: EncodedParams = {
conference: encodeURIComponent(conferenceSid),
participant: encodeURIComponent(participantSid),
muted: encodeURIComponent(muted),
Token: encodeURIComponent(this.manager.user.token),
};

this.fetchJsonWithReject<ParticipantResponse>(
`${this.serverlessProtocol}://${this.serverlessDomain}/common/flex/programmable-voice/mute-participant`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: this.buildBody(encodedParams),
},
)
.then((response: ParticipantResponse) => {
console.log(`${muted ? 'Muted' : 'Unmuted'} successful for participant`, participantSid);
resolve(response.participantsResponse.callSid);
})
.catch((error) => {
console.error(`Error ${muted ? 'muting' : 'un-muting'} participant ${participantSid}\r\n`, error);
reject(error);
});
});
};

muteParticipant = async (conference: string, participantSid: string): Promise<string> => {
return this._toggleMuteParticipant(conference, participantSid, true);
};

unmuteParticipant = async (conference: string, participantSid: string): Promise<string> => {
return this._toggleMuteParticipant(conference, participantSid, false);
};
}

export default new CallControlService();
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { FeatureDefinition } from '../../types/feature-loader';
import { isFeatureEnabled } from './config';
// @ts-ignore
import hooks from './flex-hooks/**/*.*';

export const register = (): FeatureDefinition => {
if (!isFeatureEnabled()) return {};
return { name: 'sip-mute', hooks: typeof hooks === 'undefined' ? [] : hooks };
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default interface SipMuteConfig {
enabled: boolean;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
const { prepareFlexFunction, extractStandardResponse } = require(Runtime.getFunctions()[
'common/helpers/function-helper'
].path);
const ConferenceOperations = require(Runtime.getFunctions()['common/twilio-wrappers/conference-participant'].path);

const requiredParameters = [
{ key: 'conference', purpose: 'unique ID of conference to update' },
{ key: 'participant', purpose: 'unique ID of participant to update' },
{
key: 'muted',
purpose: 'whether the participant is muted or not',
},
];

exports.handler = prepareFlexFunction(requiredParameters, async (context, event, callback, response, handleError) => {
try {
const { conference, participant, muted } = event;

const result = await ConferenceOperations.muteParticipant({
context,
conference,
participant,
muted: muted === 'true',
});

const { participantsResponse, status } = result;

response.setStatusCode(status);
response.setBody({ participantsResponse, ...extractStandardResponse(result) });
return callback(null, response);
} catch (error) {
return handleError(error);
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -302,3 +302,37 @@ exports.updateConference = async function updateConference(parameters) {
return retryHandler(error, parameters, exports.updateConference);
}
};

/**
* @param {object} parameters the parameters for the function
* @param {number} parameters.attempts the number of retry attempts performed
* @param {object} parameters.context the context from calling lambda function
* @param {string} parameters.conference the unique conference SID with the participant
* @param {string} parameters.participant the unique participant SID to modify
* @param {boolean} parameters.muted whether to mute the passed participant
* @returns {Participant} The newly updated conference participant
* @description sets endConferenceOnExit on the given conference participant
*/
exports.muteParticipant = async function updateParticipant(parameters) {
const { context, conference, participant, muted } = parameters;

if (!isObject(context))
throw new Error('Invalid parameters object passed. Parameters must contain reason context object');
if (!isString(conference))
throw new Error('Invalid parameters object passed. Parameters must contain conference string');
if (!isString(participant))
throw new Error('Invalid parameters object passed. Parameters must contain participant string');
if (!isBoolean(muted)) throw new Error('Invalid parameters object passed. Parameters must contain muted boolean');

try {
const client = context.getTwilioClient();

const participantsResponse = await client.conferences(conference).participants(participant).update({
muted,
});

return { success: true, participantsResponse, status: 200 };
} catch (error) {
return retryHandler(error, parameters, exports.updateParticipant);
}
};