├── .env.example ├── .eslintrc ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── LICENSE ├── README.md ├── jest.config.js ├── package-lock.json ├── package.json ├── provision ├── helpers │ ├── flex.js │ ├── numbers.js │ ├── serverless.js │ ├── studio.js │ └── taskrouter.js └── index.js ├── public └── appConfig.example.js ├── serverless ├── .env.example ├── assets │ └── assets │ │ ├── alertTone.mp3 │ │ └── guitar_music.mp3 ├── functions │ ├── helpers.private.js │ ├── inqueue-callback.protected.js │ ├── inqueue-utils.js │ ├── inqueue-voicemail.protected.js │ ├── options.private.js │ └── queue-menu.protected.js ├── package-lock.json └── package.json ├── src ├── InQueueMessagingPlugin.js ├── components │ ├── callback │ │ ├── CallbackComponent.js │ │ ├── CallbackContainer.js │ │ ├── CallbackStyles.js │ │ └── index.js │ ├── common │ │ ├── index.js │ │ └── inqueueUtils.js │ ├── index.js │ └── voicemail │ │ ├── VoicemailComponent.js │ │ ├── VoicemailContainer.js │ │ ├── VoicemailStyles.js │ │ └── index.js ├── helpers │ ├── http.js │ ├── index.js │ ├── logger.js │ └── urlHelper.js ├── index.js └── states │ ├── ActionInQueueMessagingState.js │ └── index.js ├── webpack.config.js └── webpack.dev.js /.env.example: -------------------------------------------------------------------------------- 1 | # Replace with your Function deployment domain name 2 | REACT_APP_SERVICE_BASE_URL="https://" 3 | 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "twilio" 4 | ], 5 | "rules": { 6 | "import/no-extraneous-dependencies": "off", 7 | "no-console": "off", 8 | "no-alert": "off", 9 | "sonarjs/no-duplicate-string": "warn", 10 | "sonarjs/no-identical-functions": "warn" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | .idea 107 | .DS_Store 108 | 109 | # Twilio serverless CLI plugin artifact 110 | .twiliodeployinfo 111 | 112 | # Flex plugin build folder 113 | build/ 114 | 115 | # Flex plugin app configuration file 116 | appConfig.js -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run lint 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Twilio Inc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | Twilio 3 | 4 | 5 | # Queued Callbacks and Voicemail for Flex 6 | 7 | The Queued Callback and Voicemail for Flex plugin helps Flex admins automate handling of agent callback requests from customers instead of having them wait longer in a queue. 8 | 9 | ## Set up 10 | 11 | ### Requirements 12 | 13 | To deploy this plugin, you will need: 14 | 15 | - An active Twilio account with Flex provisioned. Refer to the [Flex Quickstart](https://www.twilio.com/docs/flex/quickstart/flex-basics#sign-up-for-or-sign-in-to-twilio-and-create-a-new-flex-project) to create one. 16 | - npm version 5.0.0 or later installed (type `npm -v` in your terminal to check) 17 | - Node version 12.21 or later installed (type `node -v` in your terminal to check) 18 | - [Twilio CLI](https://www.twilio.com/docs/twilio-cli/quickstart#install-twilio-cli) along with the [Flex CLI Plugin](https://www.twilio.com/docs/twilio-cli/plugins#available-plugins) and the [Serverless Plugin](https://www.twilio.com/docs/twilio-cli/plugins#available-plugins). Run the following commands to install them: 19 | 20 | ``` 21 | # Install the Twilio CLI 22 | npm install twilio-cli -g 23 | # Install the Serverless and Flex as Plugins 24 | twilio plugins:install @twilio-labs/plugin-serverless 25 | twilio plugins:install @twilio-labs/plugin-flex 26 | ``` 27 | 28 | - A GitHub account 29 | - [Native Dialpad configured on your Flex instance](https://www.twilio.com/docs/flex/dialpad/enable) 30 | 31 | ### Twilio Account Settings 32 | 33 | Before we begin, we need to collect 34 | all the config values we need to run the application: 35 | 36 | | Config Value | Description | 37 | | :---------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------- | 38 | | Account Sid | Your primary Twilio account identifier - find this [in the Console](https://www.twilio.com/console). | 39 | | Serverless Deployment Domain | The resulting Serverless domain name after deploying your Twilio Functions | 40 | | Workspace SID | Your Flex Task Assignment workspace SID - find this [in the Console TaskRouter Workspaces page](https://www.twilio.com/console/taskrouter/workspaces) 41 | 42 | ### Local development 43 | 44 | After the above requirements have been met: 45 | 46 | 1. Clone this repository 47 | 48 | ``` 49 | git clone git@github.com:twilio-labs/plugin-queued-callbacks-and-voicemail.git 50 | ``` 51 | 52 | 2. Change into the `public` subdirectory of the repo and run the following: 53 | 54 | ``` 55 | cd plugin-queued-callbacks-and-voicemail/public && mv appConfig.example.js appConfig.js 56 | ``` 57 | 58 | 3. Install dependencies 59 | 60 | ```bash 61 | npm install 62 | ``` 63 | 64 | 4. [Deploy your Twilio Functions and Assets](#twilio-serverless-deployment) 65 | 66 | 5. Run the application 67 | 68 | ```bash 69 | twilio flex:plugins:start 70 | ``` 71 | 72 | See [Twilio Account Settings](#twilio-account-settings) to locate the necessary environment variables. 73 | 74 | 7. Run the application 75 | 76 | ```bash 77 | npm start 78 | ``` 79 | 80 | Alternatively, you can use this command to start the server in development mode. It will reload whenever you change any files. 81 | 82 | ```bash 83 | npm run dev 84 | ``` 85 | 86 | 8. Navigate to [http://localhost:3000](http://localhost:3000) 87 | 88 | That's it! 89 | 90 | ### Twilio Serverless deployment 91 | 92 | You need to deploy the functions associated with the Callback and Voicemail Flex plugin to your Flex instance. The functions are called from the plugin you will deploy in the next step and integrate with TaskRouter, passing in required attributes to generate the callback and voicemail tasks, depending on the customer selection while listening to the in-queue menu options. 93 | 94 | #### Pre-deployment Steps 95 | 96 | 1. From the root directory of your copy of the source code, change into `serverless` and rename `.env.example` to `.env`. 97 | 98 | ``` 99 | cd serverless && mv .env.example .env 100 | ``` 101 | 102 | 2. Open `.env` with your text editor and modify TWILIO_WORKSPACE_SID with your Flex Task Assignment SID. 103 | 104 | ``` 105 | TWILIO_WORKSPACE_SID=WSxxxxxxxxxxxxxxxxxxxxxx` 106 | ``` 107 | 108 | 3. To deploy your Callback and Voicemail functions and assets, run the following: 109 | 110 | ``` 111 | $ twilio serverless:deploy --assets 112 | 113 | ## Example Output 114 | Deploying functions & assets to the Twilio Runtime 115 | Env Variables 116 | ⠇ Creating 4 Functions 117 | ✔ Serverless project successfully deployed 118 | 119 | Deployment Details 120 | Domain: plugin-queued-callbacks-voicemail-functions-xxxx-dev.twil.io 121 | Service: 122 | plugin-queued-callbacks-voicemail-functions 123 | Functions: 124 | https://plugin-queued-callbacks-voicemail-functions-xxxx-dev.twil.io/inqueue-callback 125 | https://plugin-queued-callbacks-voicemail-functions-xxxx-dev.twil.io/inqueue-utils 126 | https://plugin-queued-callbacks-voicemail-functions-xxxx-dev.twil.io/queue-menu 127 | https://plugin-queued-callbacks-voicemail-functions-xxxx-dev.twil.io/inqueue-voicemail 128 | 129 | Assets: 130 | https://plugin-queued-callbacks-voicemail-functions-xxxx-dev.twil.io/assets/alertTone.mp3 131 | https://plugin-queued-callbacks-voicemail-functions-xxxx-dev.twil.io/assets/guitar_music.mp3 132 | ``` 133 | 134 | _Note:_ Copy and save the domain returned when you deploy a function. You will need it in the next step. If you forget to copy the domain, you can also find it by navigating to [Functions > API](https://www.twilio.com/console/functions/api) in the Twilio Console. 135 | 136 | > Debugging Tip: Pass the -l or logging flag to review deployment logs. For example, you can pass `-l debug` to turn on debugging logs. 137 | 138 | ### Deploy your Flex Plugin 139 | 140 | Once you have deployed the function, it is time to deploy the plugin to your Flex instance. 141 | 142 | Run the following commands in the plugin root directory. We will leverage the Twilio CLI to build and deploy the Plugin. 143 | 144 | 1. Rename `.env.example` to `.env`. 145 | 2. Open `.env` with your text editor and modify the `REACT_APP_SERVICE_BASE_URL` property to the Domain name you copied in the previous step. Make sure to prefix it with "https://". 146 | 147 | ``` 148 | plugin-queued-callbacks-and-voicemail $ mv .env.example .env 149 | 150 | # .env 151 | REACT_APP_SERVICE_BASE_URL=https://plugin-queued-callbacks-voicemail-functions-4135-dev.twil.io 152 | ``` 153 | 154 | 3. When you are ready to deploy the plugin, run the following in a command shell: 155 | 156 | ``` 157 | plugin-queued-callbacks-and-voicemail $ twilio flex:plugins:deploy --major --changelog "Updating to use the latest Twilio CLI Flex plugin" --description "Queued callbacks and voicemail" 158 | ``` 159 | 160 | 4. To enable the plugin on your contact center, follow the suggested next step on the deployment confirmation. To enable it via the Flex UI, see the [Plugins Dashboard documentation](https://www.twilio.com/docs/flex/developer/plugins/dashboard#stage-plugin-changes). 161 | 162 | 163 | ## Configurations 164 | 165 | The serverless implementation can be customized using the file [`options.private.js`](serverless/functions/options.private.js). Options include: 166 | 167 | * `sayOptions`: Attributes for the `` verb used to prompt the customer. You can read more about these attributes and their values on [TwiML™ Voice: ``](https://www.twilio.com/docs/voice/twiml/say) 168 | * `holdMusicUrl`: Relative or absolute path to the audio file for hold music (default: `/assets/guitar_music.mp3`). If no domain is provided (i.e. relative path), the serverless domain will be used. 169 | * `getEwt`: Enable Estimated Waiting Time in voice prompt (default: `true`) 170 | * `statPeriod`: Time interval (in minutes) for Estimated Waiting Time stats evaluation (default: `5` minutes) 171 | * `getQueuePosition`: Enable Queue Position in voice prompt (default: `true`) 172 | * `VoiceMailTaskPriority`: Priority for the Task generatared by the VoiceMail (default: `50`) 173 | * `VoiceMailAlertTone`: Relative or absolute path to the [ringback tone](https://www.twilio.com/docs/voice/twiml/dial#ringtone) that Twilio will play back to the Agent when calling a customer from a voice mail task (default: `/assets/alertTone.mp3`). If no domain is provided (i.e. relative path), the serverless domain will be used. This is not currently implemented in the Flex plugin, and it's for future usage 174 | * `CallbackTaskPriority`: Priority for the Task generatared by VoiceMail (default: `50`) 175 | * `CallbackAlertTone`: Relative or absolute path to the [ringback tone](https://www.twilio.com/docs/voice/twiml/dial#ringtone) that Twilio will play back to the Agent when calling a customer from a callback task (default: `/assets/alertTone.mp3`). If no domain is provided (i.e. relative path), the serverless domain will be used. This is not currently implemented in the Flex plugin, and it's for future usage 176 | * `TimeZone`: Timezone configuration. This is used to report time and date of voicemail (default `America/Los_Angeles`) 177 | 178 | ## License 179 | 180 | [MIT](http://www.opensource.org/licenses/mit-license.html) 181 | 182 | ## Disclaimer 183 | 184 | No warranty expressed or implied. Software is as is. 185 | 186 | [twilio]: https://www.twilio.com 187 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = (config, { isProd, isDev, isTest }) => { 2 | /** 3 | * Customize the Jest by modifying the config object. 4 | * Consult https://jestjs.io/docs/en/configuration for more information. 5 | */ 6 | 7 | return config; 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "plugin-queued-callbacks-and-voicemail", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "postinstall": "flex-plugin pre-script-check", 7 | "lint": "eslint --ext js src/ serverless/functions", 8 | "lint:fix": "npm run lint -- --fix", 9 | "prepare": "husky install" 10 | }, 11 | "dependencies": { 12 | "flex-plugin-scripts": "4.1.0", 13 | "moment": "2.24.0", 14 | "moment-timezone": "0.5.28", 15 | "react": "16.5.2", 16 | "react-dom": "16.5.2", 17 | "react-moment": "0.9.7", 18 | "url-join": "^4.0.1" 19 | }, 20 | "devDependencies": { 21 | "@twilio-labs/serverless-api": "^5.2.1", 22 | "@twilio/flex-ui": "^1", 23 | "eslint": "^7.30.0", 24 | "eslint-config-twilio": "^1.35.1", 25 | "husky": "^7.0.1", 26 | "ora": "^5.4.1", 27 | "react-test-renderer": "16.5.2", 28 | "twilio": "^3.67.0" 29 | } 30 | } -------------------------------------------------------------------------------- /provision/helpers/flex.js: -------------------------------------------------------------------------------- 1 | const fetch = require('node-fetch'); 2 | 3 | async function getFlexConfig(username, password) { 4 | try { 5 | const options = { 6 | method: 'GET', 7 | headers: { 8 | 'Content-Type': 'application/json', 9 | Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`, 10 | }, 11 | }; 12 | const response = await fetch('https://flex-api.twilio.com/v1/Configuration', options); 13 | const responseJSON = await response.json(); 14 | if (responseJSON.status >= 400) { 15 | return Promise.reject(new Error(`${responseJSON.message}. Are you sure this is a Flex Project?`)); 16 | } 17 | return responseJSON; 18 | } catch (error) { 19 | throw Promise.reject(new Error(`Error fetching Flex Configuration.\n${error}`)); 20 | } 21 | } 22 | 23 | module.exports = { getFlexConfig }; 24 | -------------------------------------------------------------------------------- /provision/helpers/numbers.js: -------------------------------------------------------------------------------- 1 | async function fetchIncomingPhoneNumbers(twilioClient) { 2 | const incomingNumbers = await twilioClient.incomingPhoneNumbers.list({ limit: 20 }); 3 | return incomingNumbers.map((number) => ({ name: number.phoneNumber, value: number.sid })); 4 | } 5 | 6 | async function updatePhoneNumber(twilioClient, inboundPhoneNumberSid, attributes) { 7 | return twilioClient.incomingPhoneNumbers(inboundPhoneNumberSid).update(attributes); 8 | } 9 | 10 | module.exports = { fetchIncomingPhoneNumbers, updatePhoneNumber }; 11 | -------------------------------------------------------------------------------- /provision/helpers/serverless.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | const { TwilioServerlessApiClient } = require('@twilio-labs/serverless-api'); 4 | 5 | async function deployServerless(username, password, env) { 6 | const serverlessClient = new TwilioServerlessApiClient({ 7 | username, 8 | password, 9 | }); 10 | 11 | const pkgJson = JSON.parse(fs.readFileSync('./serverless/package.json')); 12 | return serverlessClient.deployLocalProject({ 13 | username, 14 | password, 15 | cwd: `${process.cwd()}/serverless`, 16 | env, 17 | serviceName: pkgJson.name, 18 | pkgJson, 19 | functionsEnv: 'dev', 20 | functionsFolderName: 'functions', 21 | assetsFolderName: 'assets', 22 | overrideExistingService: true, 23 | overrideExistingProject: true, 24 | }); 25 | } 26 | 27 | module.exports = { deployServerless }; 28 | -------------------------------------------------------------------------------- /provision/helpers/studio.js: -------------------------------------------------------------------------------- 1 | function getFlowDefinition(serverlessDomain, worflowSid, voiceChannelSid) { 2 | return { 3 | description: 'Callback and Voicemail flow', 4 | states: [ 5 | { 6 | name: 'Trigger', 7 | type: 'trigger', 8 | transitions: [ 9 | { 10 | event: 'incomingMessage', 11 | }, 12 | { 13 | next: 'send_to_flex_1', 14 | event: 'incomingCall', 15 | }, 16 | { 17 | event: 'incomingRequest', 18 | }, 19 | ], 20 | properties: { 21 | offset: { 22 | x: 0, 23 | y: 0, 24 | }, 25 | }, 26 | }, 27 | { 28 | name: 'send_to_flex_1', 29 | type: 'send-to-flex', 30 | transitions: [ 31 | { 32 | event: 'callComplete', 33 | }, 34 | { 35 | event: 'failedToEnqueue', 36 | }, 37 | { 38 | event: 'callFailure', 39 | }, 40 | ], 41 | properties: { 42 | waitUrl: `https://${serverlessDomain}/queue-menu?mode=main`, 43 | offset: { 44 | x: 20, 45 | y: 370, 46 | }, 47 | workflow: worflowSid, 48 | channel: voiceChannelSid, 49 | attributes: '{ "type": "inbound", "name": "{{trigger.call.From}}", "direction": "inbound" }', 50 | waitUrlMethod: 'POST', 51 | }, 52 | }, 53 | ], 54 | // eslint-disable-next-line camelcase 55 | initial_state: 'Trigger', 56 | flags: { 57 | // eslint-disable-next-line camelcase 58 | allow_concurrent_calls: true, 59 | }, 60 | }; 61 | } 62 | 63 | async function createStudioFlow(twilioClient, workspaceSid, workflowSid, serverlessDomain) { 64 | const channelsList = await twilioClient.taskrouter.workspaces(workspaceSid).taskChannels.list(); 65 | const voiceChannel = channelsList.find((channel) => channel.friendlyName === 'Voice'); 66 | if (!voiceChannel) { 67 | throw new Error('No voice channel found in this workspace'); 68 | } 69 | 70 | const studioDefinition = getFlowDefinition(serverlessDomain, workflowSid, voiceChannel.sid); 71 | const studioFlows = await twilioClient.studio.flows.list({ limit: 100 }); 72 | const callbackVoiceMailFlow = studioFlows.find((flow) => flow.friendlyName === 'CallbackVoiceMailFlow'); 73 | if (callbackVoiceMailFlow) { 74 | return twilioClient.studio.flows(callbackVoiceMailFlow.sid).update({ 75 | status: 'published', 76 | definition: studioDefinition, 77 | }); 78 | } 79 | return twilioClient.studio.flows.create({ 80 | friendlyName: 'CallbackVoiceMailFlow', 81 | status: 'published', 82 | definition: studioDefinition, 83 | }); 84 | } 85 | 86 | module.exports = { createStudioFlow }; 87 | -------------------------------------------------------------------------------- /provision/helpers/taskrouter.js: -------------------------------------------------------------------------------- 1 | function createWorkflowConfiguration(callbackandVoicemailQueueSid, everyoneTaskQueue) { 2 | return `{ 3 | "task_routing": { 4 | "filters": [ 5 | { 6 | "filter_friendly_name": "Attempt 1", 7 | "expression": "(taskType=='callback' OR taskType=='voicemail') AND placeCallRetry==1", 8 | "targets": [ 9 | { 10 | "queue": "${callbackandVoicemailQueueSid}", 11 | "timeout": 10 12 | }, 13 | { 14 | "queue": "${everyoneTaskQueue}" 15 | } 16 | ] 17 | }, 18 | { 19 | "filter_friendly_name": "Attempt 2", 20 | "expression": "(taskType=='callback' OR taskType=='voicemail') AND placeCallRetry==2", 21 | "targets": [ 22 | { 23 | "queue": "${callbackandVoicemailQueueSid}", 24 | "timeout": 20 25 | }, 26 | { 27 | "queue": "${everyoneTaskQueue}" 28 | } 29 | ] 30 | }, 31 | { 32 | "filter_friendly_name": "Attempt 3", 33 | "expression": "(taskType=='callback' OR taskType=='voicemail') AND placeCallRetry==3", 34 | "targets": [ 35 | { 36 | "queue": "${callbackandVoicemailQueueSid}", 37 | "timeout": 30 38 | }, 39 | { 40 | "queue": "${everyoneTaskQueue}" 41 | } 42 | ] 43 | } 44 | ], 45 | "default_filter": { 46 | "queue": "${everyoneTaskQueue}" 47 | } 48 | } 49 | }`; 50 | } 51 | 52 | async function createTaskChannel(twilioClient, workspaceSid, attributes) { 53 | if (!attributes.uniqueName) { 54 | throw new Error('Provide an uniqueName for the new channel'); 55 | } 56 | try { 57 | callbackTaskChannels = await twilioClient.taskrouter.workspaces(workspaceSid).taskChannels.list(); 58 | 59 | const taskChannel = callbackTaskChannels.find((channel) => channel.uniqueName === attributes.uniqueName); 60 | if (taskChannel) { 61 | return taskChannel; 62 | } 63 | return twilioClient.taskrouter.workspaces(workspaceSid).taskChannels.create(attributes); 64 | } catch (error) { 65 | console.log(error); 66 | throw new Error('Error creating Task Channels'); 67 | } 68 | } 69 | 70 | async function createTaskQueue(twilioClient, workspaceSid, attributes) { 71 | if (!attributes.friendlyName) { 72 | throw new Error('Please provide friendlyName for the new TaskQueue'); 73 | } 74 | try { 75 | const taskQueuesList = await twilioClient.taskrouter 76 | .workspaces(workspaceSid) 77 | .taskQueues.list({ friendlyName: attributes.friendlyName }); 78 | if (taskQueuesList.length) { 79 | return taskQueuesList[0]; 80 | } 81 | return twilioClient.taskrouter.workspaces(workspaceSid).taskQueues.create(attributes); 82 | } catch (error) { 83 | console.log(error); 84 | throw new Error('Error creating TaskQueue'); 85 | } 86 | } 87 | 88 | async function fetchTaskQueue(twilioClient, workspaceSid, friendlyName) { 89 | const taskQueuesList = await twilioClient.taskrouter.workspaces(workspaceSid).taskQueues.list({ friendlyName }); 90 | if (!taskQueuesList || taskQueuesList.length === 0) { 91 | throw new Error(`TaskQueue ${friendlyName} not found`); 92 | } 93 | return taskQueuesList[0]; 94 | } 95 | 96 | async function updateWorkflow(twilioClient, workspaceSid, friendlyName, newAttributes) { 97 | const workflowsList = await twilioClient.taskrouter.workspaces(workspaceSid).workflows.list({ friendlyName }); 98 | if (!workflowsList || workflowsList.length === 0) { 99 | throw new Error(`Workflow ${friendlyName} not found`); 100 | } 101 | return twilioClient.taskrouter.workspaces(workspaceSid).workflows(workflowsList[0].sid).update(newAttributes); 102 | } 103 | 104 | module.exports = { 105 | createWorkflowConfiguration, 106 | updateWorkflow, 107 | fetchTaskQueue, 108 | createTaskQueue, 109 | createTaskChannel, 110 | }; 111 | -------------------------------------------------------------------------------- /provision/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | const inquirer = require('inquirer'); 4 | const ora = require('ora'); 5 | 6 | const { 7 | createWorkflowConfiguration, 8 | updateWorkflow, 9 | fetchTaskQueue, 10 | createTaskQueue, 11 | createTaskChannel, 12 | } = require('./helpers/taskrouter'); 13 | const { createStudioFlow } = require('./helpers/studio'); 14 | const { fetchIncomingPhoneNumbers, updatePhoneNumber } = require('./helpers/numbers'); 15 | const { getFlexConfig } = require('./helpers/flex'); 16 | const { deployServerless } = require('./helpers/serverless'); 17 | 18 | let twilioClient; 19 | let spinner; 20 | 21 | inquirer 22 | .prompt([ 23 | { 24 | type: 'input', 25 | name: 'username', 26 | message: 'Provide your Twilio Account SID', 27 | validate: this._validateAccountSid, 28 | }, 29 | { 30 | type: 'password', 31 | name: 'password', 32 | message: 'Provide your Twilio Auth Token', 33 | validate: (input) => (input.length === 32 ? true : 'Twilio Auth token has to be 32 characters long'), 34 | }, 35 | ]) 36 | .then(async (answers) => { 37 | // eslint-disable-next-line global-require 38 | twilioClient = require('twilio')(answers.username, answers.password); 39 | 40 | // Interrupt the process if there are no phone numbers 41 | const phoneNumbers = await fetchIncomingPhoneNumbers(twilioClient); 42 | if (!phoneNumbers || phoneNumbers.length === 0) { 43 | throw new Error(`There are no numbers configured on your account ${answers.username}.\nProvision a new number before starting the script again`) 44 | } 45 | 46 | // Step 2 - Fetch Flex Config 47 | spinner = ora('Fetching Flex configuration').start(); 48 | const flexConfig = await getFlexConfig(answers.username, answers.password); 49 | if (flexConfig) { 50 | flexTaskAssignmentWorkspaceSid = flexConfig.taskrouter_workspace_sid; 51 | } else { 52 | throw new Error('Error fetching Flex Config. Please check if this is a Flex project'); 53 | } 54 | 55 | // Step 3 - Create Task Channels 56 | spinner.start('Creating Task Channels'); 57 | const callbackTaskChannel = await createTaskChannel(twilioClient, flexTaskAssignmentWorkspaceSid, { 58 | friendlyName: 'callback', 59 | uniqueName: 'callback', 60 | }); 61 | const voicemailTaskChannel = await createTaskChannel(twilioClient, flexTaskAssignmentWorkspaceSid, { 62 | friendlyName: 'voicemail', 63 | uniqueName: 'voicemail', 64 | }); 65 | spinner.succeed(`Task Channels created`); 66 | 67 | // Step 4 - Create CallbackandVoicemailQueue 68 | spinner.start('Creating "CallbackandVoicemailQueue" Task Queue'); 69 | const callbackandVoicemailQueue = await createTaskQueue(twilioClient, flexTaskAssignmentWorkspaceSid, { 70 | friendlyName: 'CallbackandVoicemailQueue', 71 | targetWorker: '1==0', 72 | }); 73 | spinner.succeed('"CallbackandVoicemailQueue" Task Queue created'); 74 | 75 | // Step 5 - Update Workflow 76 | spinner.start('Updating "Assign To Anyone" workflow'); 77 | const everyoneTaskQueue = await fetchTaskQueue(twilioClient, flexTaskAssignmentWorkspaceSid, 'Everyone'); 78 | const assignToAnyoneWorkflow = await updateWorkflow( 79 | twilioClient, 80 | flexTaskAssignmentWorkspaceSid, 81 | 'Assign To Anyone', 82 | { 83 | configuration: createWorkflowConfiguration(callbackandVoicemailQueue.sid, everyoneTaskQueue.sid), 84 | }, 85 | ); 86 | spinner.succeed('"Assign To Anyone" workflow updated'); 87 | 88 | // Creating Serverless .env 89 | fs.writeFileSync('./serverless/.env', `TWILIO_WORKSPACE_SID=${flexTaskAssignmentWorkspaceSid}`); 90 | 91 | // Deploy serverless 92 | spinner.start('Deploying serverless'); 93 | const serverlessInfo = await deployServerless(answers.username, answers.password, { 94 | TWILIO_WORKSPACE_SID: flexTaskAssignmentWorkspaceSid, 95 | }); 96 | spinner.succeed(`Serverless deployed to ${serverlessInfo.domain}`); 97 | 98 | // Create Studio flow 99 | spinner.start('Creating Studio Flow'); 100 | const studioFlow = await createStudioFlow( 101 | twilioClient, 102 | flexTaskAssignmentWorkspaceSid, 103 | assignToAnyoneWorkflow.sid, 104 | serverlessInfo.domain, 105 | ); 106 | const studioFlowWebhook = `https://webhooks.twilio.com/v1/Accounts/${answers.username}/Flows/${studioFlow.sid}`; 107 | spinner.succeed('Studio Flow created'); 108 | 109 | // Associate a number to Studio flow 110 | spinner.stop(); 111 | const answer = await inquirer.prompt([ 112 | { 113 | type: 'list', 114 | name: 'phoneNumberSid', 115 | message: 'Choose a phone number', 116 | choices: phoneNumbers, 117 | }, 118 | ]); 119 | await updatePhoneNumber(twilioClient, answer.phoneNumberSid, { 120 | voiceUrl: studioFlowWebhook, 121 | statusCallback: studioFlowWebhook, 122 | statusCallbackMethod: 'POST', 123 | voiceMethod: 'POST', 124 | }); 125 | 126 | // Update plugin .env file 127 | fs.writeFileSync('.env', `REACT_APP_SERVICE_BASE_URL="https://${serverlessInfo.domain}"`); 128 | spinner.succeed( 129 | 'All done!\n\nYou can now deploy a minor or major version of the plugin ($ twilio flex:plugins:deploy --help to learn about the optional flags) using:\n $ twilio flex:plugins:deploy --changelog="First version"\n', 130 | ); 131 | }) 132 | .catch((err) => { 133 | let errorMessage = '' 134 | if (err.message == 'Authenticate') { 135 | errorMessage = 'Wrong Twilio credentials provided. Please check your Twilio Account SID and Auth Token' 136 | } else { 137 | errorMessage = err.message 138 | } 139 | console.log(errorMessage); 140 | if (spinner) { 141 | spinner.fail(); 142 | } 143 | }); 144 | -------------------------------------------------------------------------------- /public/appConfig.example.js: -------------------------------------------------------------------------------- 1 | var appConfig = { 2 | pluginService: { 3 | enabled: true, 4 | url: '/plugins', 5 | }, 6 | ytica: false, 7 | logLevel: 'info', 8 | showSupervisorDesktopView: true, 9 | }; 10 | -------------------------------------------------------------------------------- /serverless/.env.example: -------------------------------------------------------------------------------- 1 | TWILIO_WORKSPACE_SID=WSXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX -------------------------------------------------------------------------------- /serverless/assets/assets/alertTone.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio-labs/plugin-queued-callbacks-and-voicemail/f3ce17e4fc64cae001f372001b8770592bc7076e/serverless/assets/assets/alertTone.mp3 -------------------------------------------------------------------------------- /serverless/assets/assets/guitar_music.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio-labs/plugin-queued-callbacks-and-voicemail/f3ce17e4fc64cae001f372001b8770592bc7076e/serverless/assets/assets/guitar_music.mp3 -------------------------------------------------------------------------------- /serverless/functions/helpers.private.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | // Temporary disabling camelcase rule. This require a change in the plugin code 3 | 4 | const moment = require('moment-timezone'); 5 | 6 | function handleError(error) { 7 | let message = ''; 8 | if (error.message) { 9 | message += error.message; 10 | } 11 | if (error.stack) { 12 | message += ` | stack: ${error.stack}`; 13 | } 14 | (console.error || console.log).call(console, message || error); 15 | } 16 | 17 | /** 18 | * Get a Task Resource 19 | * 20 | * @param {object} context Twilio function context object 21 | * @param {string} sid Call Sid or Task Sid 22 | * @returns {Promise} Promise Object with Task Resource 23 | */ 24 | function getTask(context, sid) { 25 | const client = context.getTwilioClient(); 26 | let fetchTask; 27 | 28 | if (sid.startsWith('CA')) { 29 | fetchTask = client.taskrouter.workspaces(context.TWILIO_WORKSPACE_SID).tasks.list({ 30 | evaluateTaskAttributes: `call_sid= '${sid}'`, 31 | limit: 20, 32 | }); 33 | } else { 34 | fetchTask = client.taskrouter.workspaces(context.TWILIO_WORKSPACE_SID).tasks(sid).fetch(); 35 | } 36 | 37 | return fetchTask 38 | .then((result) => { 39 | const task = Array.isArray(result) ? result[0] : result; 40 | return { 41 | status: 'success', 42 | topic: 'getTask', 43 | action: 'getTask', 44 | taskSid: task.sid, 45 | taskQueueSid: task.taskQueueSid, 46 | taskQueueName: task.taskQueueFriendlyName, 47 | workflowSid: task.workflowSid, 48 | workspaceSid: task.workspaceSid, 49 | data: task, 50 | }; 51 | }) 52 | .catch((error) => { 53 | return { 54 | status: 'error', 55 | topic: 'getTask', 56 | action: 'getTask', 57 | data: error, 58 | }; 59 | }); 60 | } 61 | 62 | /** 63 | * 64 | * Cancel a Task 65 | * 66 | * @param {Object} client Twilio Client 67 | * @param {string} workspaceSid SID of the workspace the task belong to 68 | * @param {string} taskSid SID of the task to be cancelled 69 | */ 70 | async function cancelTask(client, workspaceSid, taskSid) { 71 | try { 72 | await client.taskrouter.workspaces(workspaceSid).tasks(taskSid).update({ 73 | assignmentStatus: 'canceled', 74 | reason: 'Voicemail Request', 75 | }); 76 | } catch (error) { 77 | console.log('cancelTask Error'); 78 | handleError(error); 79 | } 80 | } 81 | 82 | /** 83 | * 84 | * Get current time adjusted to timezone 85 | * 86 | * @param {string} timeZone Timezone name 87 | * @returns {Object} 88 | */ 89 | function getTime(timeZone) { 90 | const now = new Date(); 91 | const timeRecvd = moment(now); 92 | return { 93 | time_recvd: timeRecvd, 94 | server_tz: timeZone, 95 | server_time_long: timeRecvd.tz(timeZone).format('MMM Do YYYY, h:mm:ss a z'), 96 | server_time_short: timeRecvd.tz(timeZone).format('MM-D-YYYY, h:mm:ss a z'), 97 | }; 98 | } 99 | 100 | /** 101 | * 102 | * Build a url with query parameters 103 | * 104 | * @param {string} url Base URL 105 | * @param {Object} queries Key-value pairs for query parameters 106 | * @returns {string} 107 | */ 108 | const urlBuilder = (url, queries) => { 109 | const params = new URLSearchParams(); 110 | Object.entries(queries).forEach(([key, value]) => params.append(key, value)); 111 | return `${url}?${params}`; 112 | }; 113 | 114 | module.exports = { getTask, handleError, getTime, cancelTask, urlBuilder }; 115 | -------------------------------------------------------------------------------- /serverless/functions/inqueue-callback.protected.js: -------------------------------------------------------------------------------- 1 | /* 2 | *Synopsis: This function provide complete handling of Flex In-Queue Callback capabilities to include: 3 | * 1. Immediate call-back request to originating ANI ( Press 1), and 4 | * 2. Request a callback to separate number 5 | * 6 | *Callback task are created and linked to the originating call (Flex Insights reporting). The flex plugin provides 7 | *a UI for management of the callback request including a re-queueing capability.capability 8 | * 9 | *name: util_InQueueCallBackMenu 10 | *path: /inqueue-callback 11 | *private: CHECKED 12 | * 13 | *Function Methods (mode) 14 | * - main => main entry point for callback flow 15 | * - mainProcess => process main menu DTMF selection 16 | * - newNumber => menu initiating new number capture 17 | * - submitCallback => initiate callback creation ( getTask, cancelTask, createCallback) 18 | * 19 | *Customization: 20 | * - Set TTS voice option 21 | * - Set initial priority of callback task (default: 50) 22 | * - Set timezone configuration ( server_tz ) 23 | * 24 | *Install/Config: See documentation 25 | * 26 | *Last Updated: 07/05/2021 27 | */ 28 | 29 | const helpersPath = Runtime.getFunctions().helpers.path; 30 | const { getTask, handleError, getTime, cancelTask, urlBuilder } = require(helpersPath); 31 | const optionsPath = Runtime.getFunctions().options.path; 32 | const options = require(optionsPath); 33 | 34 | // Create the callback task 35 | async function createCallbackTask(client, phoneNumber, taskInfo, ringback) { 36 | const time = getTime(options.TimeZone); 37 | const taskAttributes = JSON.parse(taskInfo.data.attributes); 38 | 39 | const newTaskAttributes = { 40 | taskType: 'callback', 41 | ringback, 42 | to: phoneNumber || taskAttributes.caller, 43 | direction: 'inbound', 44 | name: `Callback: ${phoneNumber || taskAttributes.caller}`, 45 | from: taskAttributes.called, 46 | callTime: time, 47 | queueTargetName: taskInfo.taskQueueName, 48 | queueTargetSid: taskInfo.taskQueueSid, 49 | workflowTargetSid: taskInfo.workflowSid, 50 | // eslint-disable-next-line camelcase 51 | ui_plugin: { cbCallButtonAccessibility: false }, 52 | placeCallRetry: 1, 53 | }; 54 | try { 55 | await client.taskrouter.workspaces(taskInfo.workspaceSid).tasks.create({ 56 | attributes: JSON.stringify(newTaskAttributes), 57 | type: 'callback', 58 | taskChannel: 'callback', 59 | priority: options.CallbackTaskPriority, 60 | workflowSid: taskInfo.workflowSid, 61 | }); 62 | } catch (error) { 63 | console.log('createCallBackTask error'); 64 | handleError(error); 65 | } 66 | } 67 | 68 | function formatPhoneNumber(phoneNumber) { 69 | if (phoneNumber.startsWith('+')) { 70 | phoneNumber = phoneNumber.slice(1); 71 | } 72 | return phoneNumber.split('').join('...'); 73 | } 74 | 75 | // eslint-disable-next-line sonarjs/cognitive-complexity 76 | exports.handler = async function (context, event, callback) { 77 | const client = context.getTwilioClient(); 78 | const twiml = new Twilio.twiml.VoiceResponse(); 79 | 80 | const domain = `https://${context.DOMAIN_NAME}`; 81 | 82 | // Load options 83 | const { sayOptions, CallbackAlertTone } = options; 84 | 85 | const { mode } = event; 86 | const PhoneNumberFrom = event.From; 87 | const { CallSid } = event; 88 | const CallbackNumber = event.cbphone; 89 | const { taskSid } = event; 90 | let message = ''; 91 | let queries; 92 | 93 | // main logic for callback methods 94 | switch (mode) { 95 | // present main menu options 96 | case 'main': 97 | // main menu 98 | message = `You have requested a callback at ${formatPhoneNumber(PhoneNumberFrom)}...`; 99 | message += 'If this is correct, press 1...'; 100 | message += 'Press 2 to be called at different number'; 101 | 102 | queries = { 103 | mode: 'mainProcess', 104 | CallSid, 105 | cbphone: encodeURI(PhoneNumberFrom), 106 | }; 107 | if (taskSid) { 108 | queries.taskSid = taskSid; 109 | } 110 | const gatherConfirmation = twiml.gather({ 111 | input: 'dtmf', 112 | timeout: '2', 113 | action: urlBuilder(`${domain}/inqueue-callback`, queries), 114 | }); 115 | gatherConfirmation.say(sayOptions, message); 116 | twiml.redirect(`${domain}/queue-menu?mode=main${taskSid ? `&taskSid=${taskSid}` : ''}`); 117 | return callback(null, twiml); 118 | break; 119 | 120 | // process main menu selections 121 | case 'mainProcess': 122 | switch (event.Digits) { 123 | // existing number 124 | case '1': 125 | // redirect to submitCalBack 126 | queries = { 127 | mode: 'submitCallback', 128 | CallSid, 129 | cbphone: encodeURI(CallbackNumber), 130 | }; 131 | if (taskSid) { 132 | queries.taskSid = taskSid; 133 | } 134 | twiml.redirect(urlBuilder(`${domain}/inqueue-callback`, queries)); 135 | return callback(null, twiml); 136 | break; 137 | // new number 138 | case '2': 139 | message = 'Using your keypad, enter in your phone number...'; 140 | message += 'Press the pound sign when you are done...'; 141 | 142 | queries = { 143 | mode: 'newNumber', 144 | CallSid, 145 | cbphone: encodeURI(CallbackNumber), 146 | }; 147 | if (taskSid) { 148 | queries.taskSid = taskSid; 149 | } 150 | const GatherNewNumber = twiml.gather({ 151 | input: 'dtmf', 152 | timeout: '10', 153 | finishOnKey: '#', 154 | action: urlBuilder(`${domain}/inqueue-callback`, queries), 155 | }); 156 | GatherNewNumber.say(sayOptions, message); 157 | 158 | queries.mode = 'main'; 159 | twiml.redirect(urlBuilder(`${domain}/inqueue-callback`, queries)); 160 | return callback(null, twiml); 161 | break; 162 | case '*': 163 | queries = { 164 | mode: 'main', 165 | skipGreeting: true, 166 | CallSid, 167 | }; 168 | if (taskSid) { 169 | queries.taskSid = taskSid; 170 | } 171 | twiml.redirect(urlBuilder(`${domain}/inqueue-callback`, queries)); 172 | return callback(null, twiml); 173 | break; 174 | default: 175 | queries = { 176 | mode: 'main', 177 | }; 178 | if (taskSid) { 179 | queries.taskSid = taskSid; 180 | } 181 | twiml.say(sayOptions, 'I did not understand your selection.'); 182 | twiml.redirect(urlBuilder(`${domain}/inqueue-callback`, queries)); 183 | return callback(null, twiml); 184 | break; 185 | } 186 | break; 187 | 188 | // present new number menu selections 189 | case 'newNumber': 190 | const NewPhoneNumber = event.Digits; 191 | // TODO: Handle country code in new number 192 | 193 | message = `You entered ${formatPhoneNumber(NewPhoneNumber)} ...`; 194 | message += 'Press 1 if this is correct...'; 195 | message += 'Press 2 to re-enter your number'; 196 | message += 'Press the star key to return to the main menu'; 197 | 198 | queries = { 199 | mode: 'mainProcess', 200 | CallSid, 201 | cbphone: encodeURI(NewPhoneNumber), 202 | }; 203 | if (taskSid) { 204 | queries.taskSid = taskSid; 205 | } 206 | const GatherConfirmNewNumber = twiml.gather({ 207 | input: 'dtmf', 208 | timeout: '5', 209 | finishOnKey: '#', 210 | action: urlBuilder(`${domain}/inqueue-callback`, queries), 211 | }); 212 | GatherConfirmNewNumber.say(sayOptions, message); 213 | 214 | queries.mode = 'main'; 215 | twiml.redirect(urlBuilder(`${domain}/inqueue-callback`, queries)); 216 | return callback(null, twiml); 217 | break; 218 | 219 | // handler to submit the callback 220 | case 'submitCallback': 221 | /* 222 | * Steps 223 | * 1. Fetch TaskSid ( read task w/ attribute of call_sid); 224 | * 2. Update existing task (assignmentStatus==>'canceled'; reason==>'callback requested' ) 225 | * 3. Create new task ( callback ); 226 | * 4. Hangup callback 227 | * 228 | * main callback logic 229 | * get taskSid based on callSid 230 | * taskInfo = { "sid" : , "queueTargetName" : , "queueTargetSid" : }; 231 | */ 232 | const taskInfo = await getTask(context, taskSid || CallSid); 233 | 234 | // Cancel current Task 235 | await cancelTask(client, context.TWILIO_WORKSPACE_SID, taskInfo.taskSid); 236 | // Create the callback task 237 | const ringBackUrl = CallbackAlertTone.startsWith('https://') ? CallbackAlertTone : domain + CallbackAlertTone; 238 | await createCallbackTask(client, CallbackNumber, taskInfo, ringBackUrl); 239 | 240 | // hangup the call 241 | twiml.say(sayOptions, 'Your callback request has been delivered...'); 242 | twiml.say(sayOptions, 'An available care specialist will reach out to contact you...'); 243 | twiml.say(sayOptions, 'Thank you for your call.'); 244 | twiml.hangup(); 245 | return callback(null, twiml); 246 | break; 247 | default: 248 | return callback(500, 'Mode not specified'); 249 | break; 250 | } 251 | }; 252 | -------------------------------------------------------------------------------- /serverless/functions/inqueue-utils.js: -------------------------------------------------------------------------------- 1 | /* 2 | *Synopsis: This function provides supporting UTILITY functions for handling of Flex In-Queue Callback/Voicemail capabilities to include: 3 | * 1. Re-queuing of callback and voicemail tasks; 4 | * 2. Deletion of voicemail call recording media and transcripts 5 | * 6 | *These UTILITY methods directly support FLEX plugin functionality initiated by the Flex agent (worker) 7 | * 8 | *name: util_InQueueFlexUtils 9 | *path: /inqueue-utils 10 | *private: UNCHECKED 11 | * 12 | *Function Methods (mode) 13 | * - deleteRecordResources => logic for deletion of recording media and transcript text (recordingSid, transcriptionSid) 14 | * - requeueTasks => logic for re-queuing of callback/voicemail task (create new task from existing task attributes) 15 | * 16 | *Customization: 17 | * - None 18 | * 19 | *Install/Config: See documentation 20 | */ 21 | 22 | const axios = require('axios'); 23 | const JWEValidator = require('twilio-flex-token-validator').functionValidator; 24 | 25 | const helpersPath = Runtime.getFunctions().helpers.path; 26 | const { handleError } = require(helpersPath); 27 | 28 | // eslint-disable-next-line sonarjs/cognitive-complexity 29 | exports.handler = JWEValidator(async function (context, event, callback) { 30 | // setup twilio client 31 | const client = context.getTwilioClient(); 32 | 33 | const resp = new Twilio.Response(); 34 | const headers = { 35 | 'Access-Control-Allow-Origin': '*', 36 | 'Access-Control-Allow-Methods': 'GET POST,OPTIONS', 37 | 'Access-Control-Allow-Headers': 'Content-Type', 38 | 'Content-Type': 'application/json', 39 | }; 40 | resp.setHeaders(headers); 41 | 42 | // get method 43 | const { mode } = event; 44 | 45 | // global function to update callback Task attributes 46 | // controlling the UI call button view 47 | async function PluginTaskUpdate(type, taskSid, attr, state) { 48 | if (type === 'callback') { 49 | attr.ui_plugin.cbCallButtonAccessibility = event.state; 50 | } 51 | if (type === 'voicemail') { 52 | attr.ui_plugin.vmCallButtonAccessibility = event.state; 53 | attr.ui_plugin.vmRecordButtonAccessibility = !event.state; 54 | } 55 | 56 | // update task attributes 57 | await client.taskrouter 58 | .workspaces(context.TWILIO_WORKSPACE_SID) 59 | .tasks(taskSid) 60 | .update({ 61 | attributes: JSON.stringify(attr), 62 | }) 63 | .then((result) => { 64 | return { status: 'success', type: 'cbUpdateAttr', data: result }; 65 | }) 66 | .catch((error) => { 67 | return { status: 'error', type: 'cbUpdateAttr', data: error }; 68 | }); 69 | // error - updateTask 70 | } 71 | 72 | switch (mode) { 73 | case 'deleteRecordResources': 74 | /* 75 | * method to delete existing recording/transcription resources 76 | * 1. get existing task attributes 77 | * 2. update existing task attributes to indicate resource deletion 78 | * 3. delete transcription resouce; delete recording resource 79 | */ 80 | 81 | function getTask(taskSid) { 82 | return client.taskrouter 83 | .workspaces(context.TWILIO_WORKSPACE_SID) 84 | .tasks(taskSid) 85 | .fetch() 86 | .then((task) => { 87 | return { 88 | status: 'success', 89 | type: 'getTask', 90 | attr: JSON.parse(task.attributes), 91 | data: task, 92 | }; 93 | }) 94 | .catch(async (error) => { 95 | return { status: 'error', type: 'getTask', data: error }; 96 | }); 97 | } 98 | 99 | function updateTask(taskSid, attr) { 100 | if (!attr.hasOwnProperty('markDeleted')) { 101 | attr = Object.assign(attr, { markDeleted: true }); 102 | } 103 | 104 | // update task attributes 105 | return client.taskrouter 106 | .workspaces(context.TWILIO_WORKSPACE_SID) 107 | .tasks(taskSid) 108 | .update({ 109 | attributes: JSON.stringify(attr), 110 | }) 111 | .then((result) => { 112 | return { status: 'success', type: 'updateAttr', data: result }; 113 | }) 114 | .catch((error) => { 115 | return { status: 'error', type: 'updateAttr', data: error }; 116 | }); 117 | } 118 | 119 | // delete the transcription resource 120 | function deleteTranscription(transSid) { 121 | return client 122 | .transcriptions(transSid) 123 | .remove() 124 | .then(() => { 125 | return { delTransStatus: 'success', msg: '' }; 126 | }) 127 | .catch((error) => { 128 | return { delTransStatus: 'success', msg: error }; 129 | }); 130 | } 131 | 132 | // delete the call recording resource 133 | function deleteRecord(recSid) { 134 | return client 135 | .recordings(recSid) 136 | .remove() 137 | .then(() => { 138 | return { delRecStatus: 'success', msg: error }; 139 | }) 140 | .catch((error) => { 141 | return { delRecStatus: 'error', msg: error }; 142 | }); 143 | } 144 | 145 | // main logic 146 | 147 | const taskInfo = await getTask(event.taskSid); 148 | const cancelTaskResult = await updateTask(event.taskSid, taskInfo.attr); 149 | await deleteTranscription(event.transcriptionSid); 150 | await deleteRecord(event.recordingSid); 151 | 152 | return callback(null, resp.setBody(cancelTaskResult)); 153 | 154 | break; 155 | 156 | case 'requeueTasks': 157 | // handler to create new task 158 | function newTask(workflowSid, attr) { 159 | return client.taskrouter 160 | .workspaces(context.TWILIO_WORKSPACE_SID) 161 | .tasks.create({ 162 | taskChannel: attr.taskType, 163 | priority: 50, 164 | workflowSid, 165 | attributes: JSON.stringify(attr), 166 | }) 167 | .catch((error) => { 168 | console.log('newTask error'); 169 | handleError(error); 170 | return Promise.reject(error); 171 | }); 172 | } 173 | 174 | // handler to update the existing task 175 | function completeTask(taskSid) { 176 | return client.taskrouter 177 | .workspaces(context.TWILIO_WORKSPACE_SID) 178 | .tasks(taskSid) 179 | .update({ 180 | assignmentStatus: 'completed', 181 | reason: 'task transferred', 182 | }) 183 | .catch((error) => { 184 | console.log('completeTask error'); 185 | handleError(error); 186 | return Promise.reject(error); 187 | }); 188 | } 189 | 190 | // main logic for requeue execution 191 | let newAttributes = event.attributes; 192 | // increment the callCountRetry counter 193 | if (newAttributes.hasOwnProperty('placeCallRetry')) { 194 | newAttributes = Object.assign(newAttributes, { 195 | placeCallRetry: parseInt(event.attributes.placeCallRetry, 10) + 1, 196 | }); 197 | } 198 | 199 | /* 200 | * setup new task's attributes such that its linked to the 201 | * original task in Twilio WFO 202 | */ 203 | if (!newAttributes.hasOwnProperty('conversations')) { 204 | // eslint-disable-next-line camelcase 205 | newAttributes = { ...newAttributes, conversations: { conversation_id: event.taskSid } }; 206 | } 207 | // create new task 208 | await PluginTaskUpdate(event.type, event.taskSid, event.attributes, event.state); 209 | await newTask(event.workflowSid, newAttributes); 210 | // update existing task 211 | const completedTask = await completeTask(event.taskSid); 212 | 213 | return callback(null, resp.setBody(completedTask)); 214 | break; 215 | 216 | case 'UiPlugin': 217 | const tsk = await PluginTaskUpdate(event.type, event.taskSid, event.attributes, event.state); 218 | return callback(null, resp.setBody(tsk)); 219 | 220 | break; 221 | default: 222 | return callback(500, 'Mode not specified'); 223 | break; 224 | } 225 | }); 226 | -------------------------------------------------------------------------------- /serverless/functions/inqueue-voicemail.protected.js: -------------------------------------------------------------------------------- 1 | /* 2 | *Synopsis: This function provides complete handling of Flex In-Queue Voicemail capabilities to include: 3 | * 1. request to leave a voicemail with callback to originating ANI 4 | * 5 | *Voicemail tasks are created and linked to the originating call (Flex Insights reporting). The flex plugin provides 6 | *a UI for management of the voicemail request including a re-queueing capability. 7 | * 8 | *name: util_InQueueVoicemailMenu 9 | *path: /inqueue-voicemail 10 | *private: CHECKED 11 | * 12 | *Function Methods (mode) 13 | * - pre-process => main entry point for queue-back voicemail flow (redirect call, getTask, cancel Task) 14 | * - main => process main menu DTMF selection 15 | * - success => menu initiating new number capture 16 | * - submitVoicemail => create voicemail task 17 | * 18 | *Customization: 19 | * - Set TTS voice option 20 | * - Set initial priority of callback task (default: 50) 21 | * - Set timezone configuration ( server_tz ) 22 | * 23 | *Install/Config: See documentation 24 | * 25 | *Last Updated: 03/27/2020 26 | */ 27 | 28 | const helpersPath = Runtime.getFunctions().helpers.path; 29 | const { getTask, cancelTask, getTime, handleError } = require(helpersPath); 30 | const optionsPath = Runtime.getFunctions().options.path; 31 | const options = require(optionsPath); 32 | 33 | // create the voicemail task 34 | async function createVoicemailTask(event, client, taskInfo, ringback) { 35 | const time = getTime(options.TimeZone); 36 | 37 | const taskAttributes = { 38 | taskType: 'voicemail', 39 | ringback, 40 | to: event.Caller, // Inbound caller 41 | direction: 'inbound', 42 | name: `Voicemail: ${event.Caller}`, 43 | from: event.Called, // Twilio Number 44 | recordingUrl: event.RecordingUrl, 45 | recordingSid: event.RecordingSid, 46 | transcriptionSid: event.TranscriptionSid, 47 | transcriptionText: event.TranscriptionStatus === 'completed' ? event.TranscriptionText : 'Transcription failed', 48 | callTime: time, 49 | queueTargetName: taskInfo.taskQueueName, 50 | queueTargetSid: taskInfo.taskQueueSid, 51 | workflowTargetSid: taskInfo.workflowSid, 52 | // eslint-disable-next-line camelcase 53 | ui_plugin: { 54 | vmCallButtonAccessibility: false, 55 | vmRecordButtonAccessibility: true, 56 | }, 57 | placeCallRetry: 1, 58 | }; 59 | 60 | try { 61 | await client.taskrouter.workspaces(taskInfo.workspaceSid).tasks.create({ 62 | attributes: JSON.stringify(taskAttributes), 63 | type: 'voicemail', 64 | taskChannel: 'voicemail', 65 | priority: options.VoiceMailTaskPriority, 66 | workflowSid: taskInfo.workflowSid, 67 | }); 68 | } catch (error) { 69 | console.log('createVoicemailTask Error'); 70 | handleError(error); 71 | } 72 | } 73 | 74 | exports.handler = async function (context, event, callback) { 75 | const client = context.getTwilioClient(); 76 | const twiml = new Twilio.twiml.VoiceResponse(); 77 | const domain = `https://${context.DOMAIN_NAME}`; 78 | 79 | const { CallSid } = event; 80 | const { mode } = event; 81 | let { taskSid } = event; 82 | 83 | // Load options 84 | const { sayOptions, VoiceMailAlertTone } = options; 85 | 86 | // main logic for callback methods 87 | switch (mode) { 88 | // initial logic to cancel the task and prepare the call for Recording 89 | case 'pre-process': 90 | // Get taskSid based on taskSid or CallSid 91 | if (!taskSid) { 92 | const taskInfo = await getTask(context, CallSid); 93 | ({ taskSid } = taskInfo); 94 | } 95 | 96 | // Redirect Call to Voicemail main menu 97 | const redirectUrl = `${domain}/inqueue-voicemail?mode=main${taskSid ? `&taskSid=${taskSid}` : ''}`; 98 | try { 99 | await client.calls(CallSid).update({ method: 'POST', url: redirectUrl }); 100 | } catch (error) { 101 | console.log('updateCall Error'); 102 | handleError(error); 103 | } 104 | 105 | // Cancel (update) the task given taskSid 106 | await cancelTask(client, context.TWILIO_WORKSPACE_SID, taskSid); 107 | 108 | return callback(null, ''); 109 | break; 110 | 111 | case 'main': 112 | // Main logic for Recording the voicemail 113 | twiml.say(sayOptions, 'Please leave a message at the tone. Press the star key when finished.'); 114 | twiml.record({ 115 | action: `${domain}/inqueue-voicemail?mode=success&CallSid=${CallSid}`, 116 | transcribeCallback: `${domain}/inqueue-voicemail?mode=submitVoicemail&CallSid=${CallSid}`, 117 | method: 'GET', 118 | playBeep: 'true', 119 | transcribe: true, 120 | timeout: 10, 121 | finishOnKey: '*', 122 | }); 123 | twiml.say(sayOptions, 'I did not capture your recording'); 124 | return callback(null, twiml); 125 | break; 126 | 127 | // End the voicemail interaction - hang up call 128 | case 'success': 129 | twiml.say(sayOptions, 'Your voicemail has been successfully received... goodbye'); 130 | twiml.hangup(); 131 | return callback(null, twiml); 132 | break; 133 | 134 | /* 135 | * handler to submit the callback 136 | * create the task here 137 | */ 138 | case 'submitVoicemail': 139 | /* 140 | * Steps 141 | * 1. Fetch TaskSid ( read task w/ attribute of call_sid); 142 | * 2. Update existing task (assignmentStatus==>'canceled'; reason==>'callback requested' ) 143 | * 3. Create new task ( callback ); 144 | * 4. Hangup callback 145 | * 146 | * main callback logic 147 | */ 148 | const taskInfo = await getTask(context, taskSid || CallSid); 149 | // TODO: handle error in getTask 150 | 151 | // create the Voicemail task 152 | const ringBackUrl = VoiceMailAlertTone.startsWith('https://') ? VoiceMailAlertTone : domain + VoiceMailAlertTone; 153 | await createVoicemailTask(event, client, taskInfo, ringBackUrl); 154 | return callback(null, ''); 155 | break; 156 | default: 157 | return callback(500, 'Mode not specified'); 158 | break; 159 | } 160 | }; 161 | -------------------------------------------------------------------------------- /serverless/functions/options.private.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | sayOptions: { voice: 'Polly.Joanna' }, 3 | holdMusicUrl: '/assets/guitar_music.mp3', 4 | // Enable Estimated Waiting Time in voice prompt 5 | getEwt: true, 6 | // Time interval (minutes) for Estimated Waiting Time stats 7 | statPeriod: 5, 8 | // Enable Queue Position in voice prompt 9 | getQueuePosition: true, 10 | // Priority for the Task generatared by the VoiceMail 11 | VoiceMailTaskPriority: 50, 12 | // Agent audible alert sound file for voice mail 13 | VoiceMailAlertTone: '/assets/alertTone.mp3', 14 | // Priority for the Task generatared by the VoiceMail 15 | CallbackTaskPriority: 50, 16 | // Agent audible alert sound file for callback call 17 | CallbackAlertTone: '/assets/alertTone.mp3', 18 | // Timezone configuration 19 | TimeZone: 'America/Los_Angeles', 20 | }; 21 | -------------------------------------------------------------------------------- /serverless/functions/queue-menu.protected.js: -------------------------------------------------------------------------------- 1 | /* 2 | *Synopsis: This function provides complete handling of Flex In-Queue Voicemail capabilities to include: 3 | * 1. request to leave a voicemail with callback to originating ANI 4 | * 5 | *Voicemail tasks are created and linked to the originating call (Flex Insights reporting). The flex plugin provides 6 | *a UI for management of the voicemail request including a re-queueing capability. 7 | * 8 | *name: util_inQueueMenuMain 9 | *path: /queue-menu 10 | *private: CHECKED 11 | * 12 | *Function Methods (mode) 13 | * - main => present menu for in-queue main menu options 14 | * - mainProcess => present menu for main menu options (1=>Stay Queue; 2=>Callback; 3=>Voicemail) 15 | * - menuProcess => process DTMF for redirect to supporting functions (Callback, voicemail) 16 | * 17 | *Customization: 18 | * - Set TTS voice option 19 | * - Set hold music path to ASSET resource (trimmed 30 seconds source) 20 | * 21 | *Install/Config: See documentation 22 | * 23 | *Last Updated: 03/27/2020 24 | */ 25 | const axios = require('axios'); 26 | const moment = require('moment'); 27 | 28 | const helpersPath = Runtime.getFunctions().helpers.path; 29 | const { getTask, handleError } = require(helpersPath); 30 | const optionsPath = Runtime.getFunctions().options.path; 31 | const options = require(optionsPath); 32 | 33 | // retrieve workflow cummulative statistics for Estimated wait time 34 | async function getWorkflowCummStats(client, workspaceSid, workflowSid, statPeriod) { 35 | return client.taskrouter 36 | .workspaces(workspaceSid) 37 | .workflows(workflowSid) 38 | .cumulativeStatistics({ 39 | Minutes: statPeriod, 40 | }) 41 | .fetch() 42 | .then((workflowStatistics) => { 43 | return { 44 | status: 'success', 45 | topic: 'getWorkflowCummStats', 46 | action: 'getWorkflowCummStats', 47 | data: workflowStatistics, 48 | }; 49 | }) 50 | .catch((error) => { 51 | handleError(error); 52 | return { 53 | status: 'error', 54 | topic: 'getWorkflowCummStats', 55 | action: 'getWorkflowCummStats', 56 | data: error, 57 | }; 58 | }); 59 | } 60 | 61 | function getTaskPositionInQueue(client, taskInfo) { 62 | return client.taskrouter 63 | .workspaces(taskInfo.workspaceSid) 64 | .tasks.list({ 65 | assignmentStatus: 'pending, reserved', 66 | taskQueueName: taskInfo.taskQueueName, 67 | ordering: 'DateCreated:asc,Priority:desc', 68 | limit: 20, 69 | }) 70 | .then((taskList) => { 71 | const taskPosition = taskList.findIndex((task) => task.sid === taskInfo.taskSid); 72 | return { 73 | status: 'success', 74 | topic: 'getTaskList', 75 | action: 'getTaskList', 76 | position: taskPosition, 77 | data: taskList, 78 | }; 79 | }) 80 | .catch((error) => { 81 | handleError(error); 82 | return { 83 | status: 'error', 84 | topic: 'getTaskList', 85 | action: 'getTaskList', 86 | data: error, 87 | }; 88 | }); 89 | } 90 | 91 | function getAverageWaitTime(t) { 92 | const durationInSeconds = moment.duration(t.avg, 'seconds'); 93 | return { 94 | type: 'avgWaitTime', 95 | hours: durationInSeconds._data.hours, 96 | minutes: durationInSeconds._data.minutes, 97 | seconds: durationInSeconds._data.seconds, 98 | }; 99 | } 100 | 101 | // eslint-disable-next-line complexity, sonarjs/cognitive-complexity, func-names 102 | exports.handler = async function (context, event, callback) { 103 | const client = context.getTwilioClient(); 104 | const domain = `https://${context.DOMAIN_NAME}`; 105 | const twiml = new Twilio.twiml.VoiceResponse(); 106 | 107 | // Retrieve options 108 | const { sayOptions, holdMusicUrl, statPeriod, getEwt, getQueuePosition } = options; 109 | 110 | // Retrieve event arguments 111 | const CallSid = event.CallSid || ''; 112 | let { taskSid } = event; 113 | 114 | // Variables initialization 115 | const { mode } = event; 116 | let message = ''; 117 | 118 | // Variables for EWT/PostionInQueue 119 | let waitMsg = ''; 120 | let posQueueMsg = ''; 121 | let gather; 122 | 123 | /* 124 | * ========================== 125 | * BEGIN: Main logic 126 | */ 127 | switch (mode) { 128 | case 'main': 129 | // logic for retrieval of Estimated Wait Time 130 | let taskInfo; 131 | if (getEwt || getQueuePosition) { 132 | taskInfo = await getTask(context, taskSid || CallSid); 133 | if (!taskSid) { 134 | ({ taskSid } = taskInfo); 135 | } 136 | } 137 | 138 | if (getEwt && taskInfo.status === 'success') { 139 | const workflowStats = await getWorkflowCummStats( 140 | client, 141 | context.TWILIO_WORKSPACE_SID, 142 | taskInfo.workflowSid, 143 | statPeriod, 144 | ); 145 | // Get max, avg, min wait times for the workflow 146 | const t = workflowStats.data.waitDurationUntilAccepted; 147 | const ewt = getAverageWaitTime(t).minutes; 148 | 149 | let waitTts = ''; 150 | switch (ewt) { 151 | case 0: 152 | waitTts = 'less than a minute...'; 153 | break; 154 | case 4: 155 | waitTts = 'more than 4 minutes...'; 156 | break; 157 | default: 158 | waitTts = `less than ${ewt + 1} minutes...`; 159 | } 160 | 161 | waitMsg += `The estimated wait time is ${waitTts} ....`; 162 | } 163 | 164 | // Logic for Position in Queue 165 | if (getQueuePosition && taskInfo.status === 'success') { 166 | const taskPositionInfo = await getTaskPositionInQueue(client, taskInfo); 167 | switch (taskPositionInfo.position) { 168 | case 0: 169 | posQueueMsg = 'Your call is next in queue.... '; 170 | break; 171 | case 1: 172 | posQueueMsg = 'There is one caller ahead of you...'; 173 | break; 174 | case -1: 175 | posQueueMsg = 'There are more than 20 callers ahead of you...'; 176 | break; 177 | default: 178 | posQueueMsg = `There are ${taskPositionInfo.position} callers ahead of you...`; 179 | break; 180 | } 181 | } 182 | 183 | if (event.skipGreeting !== 'true') { 184 | let initGreeting = waitMsg + posQueueMsg; 185 | initGreeting += '...Please wait while we direct your call to the next available specialist...'; 186 | twiml.say(sayOptions, initGreeting); 187 | } 188 | message = 'To listen to a menu of options while on hold, press 1 at anytime.'; 189 | gather = twiml.gather({ 190 | input: 'dtmf', 191 | timeout: '2', 192 | action: `${domain}/queue-menu?mode=mainProcess${taskSid ? `&taskSid=${taskSid}` : ''}`, 193 | }); 194 | gather.say(sayOptions, message); 195 | gather.play(domain + holdMusicUrl); 196 | twiml.redirect(`${domain}/queue-menu?mode=main${taskSid ? `&taskSid=${taskSid}` : ''}`); 197 | return callback(null, twiml); 198 | break; 199 | case 'mainProcess': 200 | if (event.Digits === '1') { 201 | message = 'The following options are available...'; 202 | message += 'Press 1 to remain on hold...'; 203 | message += 'Press 2 to request a callback...'; 204 | message += 'Press 3 to leave a voicemail message for the care team...'; 205 | message += 'Press the star key to listen to these options again...'; 206 | 207 | gather = twiml.gather({ 208 | input: 'dtmf', 209 | timeout: '1', 210 | action: `${domain}/queue-menu?mode=menuProcess${taskSid ? `&taskSid=${taskSid}` : ''}`, 211 | }); 212 | gather.say(sayOptions, message); 213 | gather.play(domain + holdMusicUrl); 214 | twiml.redirect(`${domain}/queue-menu?mode=main${taskSid ? `&taskSid=${taskSid}` : ''}`); 215 | return callback(null, twiml); 216 | } 217 | twiml.say(sayOptions, 'I did not understand your selection.'); 218 | twiml.redirect(`${domain}/queue-menu?mode=main&skipGreeting=true${taskSid ? `&taskSid=${taskSid}` : ''}`); 219 | return callback(null, twiml); 220 | break; 221 | case 'menuProcess': 222 | switch (event.Digits) { 223 | // stay in queue 224 | case '1': 225 | /* 226 | * stay in queue 227 | * twiml.say(sayOptions, 'Please wait for the next available agent'); 228 | */ 229 | twiml.redirect(`${domain}/queue-menu?mode=main&skipGreeting=true${taskSid ? `&taskSid=${taskSid}` : ''}`); 230 | return callback(null, twiml); 231 | break; 232 | // request a callback 233 | case '2': 234 | twiml.redirect(`${domain}/inqueue-callback?mode=main${taskSid ? `&taskSid=${taskSid}` : ''}`); 235 | return callback(null, twiml); 236 | break; 237 | // leave a voicemail 238 | case '3': 239 | twiml.redirect(`${domain}/inqueue-voicemail?mode=pre-process${taskSid ? `&taskSid=${taskSid}` : ''}`); 240 | return callback(null, twiml); 241 | break; 242 | 243 | // listen options menu again 244 | case '*': 245 | twiml.redirect(`${domain}/queue-menu?mode=mainProcess&Digits=1${taskSid ? `&taskSid=${taskSid}` : ''}`); 246 | return callback(null, twiml); 247 | break; 248 | 249 | // listen to menu again 250 | default: 251 | twiml.say(sayOptions, 'I did not understand your selection.'); 252 | twiml.redirect(`${domain}/queue-menu?mode=mainProcess&Digits=1${taskSid ? `&taskSid=${taskSid}` : ''}`); 253 | return callback(null, twiml); 254 | break; 255 | } 256 | break; 257 | default: 258 | return callback(500, null); 259 | break; 260 | } 261 | }; 262 | -------------------------------------------------------------------------------- /serverless/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "plugin-queued-callbacks-voicemail-functions", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1", 7 | "start": "twilio-run --env" 8 | }, 9 | "devDependencies": { 10 | "twilio": "^3.65.0", 11 | "twilio-run": "^3.1.1" 12 | }, 13 | "engines": { 14 | "node": "12" 15 | }, 16 | "dependencies": { 17 | "axios": "^0.21.1", 18 | "moment": "^2.29.1", 19 | "moment-timezone": "^0.5.33", 20 | "twilio-flex-token-validator": "^1.5.6" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/InQueueMessagingPlugin.js: -------------------------------------------------------------------------------- 1 | import { VERSION } from '@twilio/flex-ui'; 2 | import { FlexPlugin } from 'flex-plugin'; 3 | import PhoneCallbackIcon from '@material-ui/icons/PhoneCallback'; 4 | import React from 'react'; 5 | import VoicemailIcon from '@material-ui/icons/Voicemail'; 6 | 7 | import { logger } from './helpers'; 8 | import reducers, { namespace } from './states'; 9 | import { CallbackComponent, VoicemailComponent } from './components'; 10 | 11 | const PLUGIN_NAME = 'InQueueMessagingPlugin'; 12 | 13 | export default class InQueueMessagingPlugin extends FlexPlugin { 14 | constructor() { 15 | super(PLUGIN_NAME); 16 | } 17 | 18 | /** 19 | * This code is run when your plugin is being started 20 | * Use this to modify any UI components or attach to the actions framework 21 | * 22 | * @param flex { typeof import('@twilio/flex-ui') } 23 | * @param manager { import('@twilio/flex-ui').Manager } 24 | */ 25 | async init(flex, manager) { 26 | this.registerReducers(manager); 27 | 28 | this.registerCallbackChannel(flex, manager); 29 | this.registerVoicemailChannel(flex, manager); 30 | } 31 | 32 | /** 33 | * Registers the {@link CallbackComponent} 34 | */ 35 | registerCallbackChannel(flex, manager) { 36 | // Create Voicemail Channel 37 | const CallbackChannel = flex.DefaultTaskChannels.createDefaultTaskChannel( 38 | 'callback', 39 | (task) => task.taskChannelUniqueName === 'callback', 40 | 'CallbackIcon', 41 | 'CallbackIcon', 42 | 'palegreen', 43 | ); 44 | // Basic Voicemail Channel Settings 45 | CallbackChannel.templates.TaskListItem.firstLine = (task) => `${task.queueName}: ${task.attributes.name}`; 46 | CallbackChannel.templates.TaskCanvasHeader.title = (task) => `${task.queueName}: ${task.attributes.name}`; 47 | CallbackChannel.templates.IncomingTaskCanvas.firstLine = (task) => task.queueName; 48 | // Lead Channel Icon 49 | CallbackChannel.icons.active = ; 50 | CallbackChannel.icons.list = ; 51 | CallbackChannel.icons.main = ; 52 | // Register Lead Channel 53 | flex.TaskChannels.register(CallbackChannel); 54 | 55 | flex.TaskInfoPanel.Content.replace(, { 56 | sortOrder: -1, 57 | if: (props) => props.task.attributes.taskType === 'callback', 58 | }); 59 | } 60 | 61 | /** 62 | * Registers the {@link VoicemailComponent} 63 | */ 64 | registerVoicemailChannel(flex, manager) { 65 | const VoiceMailChannel = flex.DefaultTaskChannels.createDefaultTaskChannel( 66 | 'voicemail', 67 | (task) => task.taskChannelUniqueName === 'voicemail', 68 | 'VoicemailIcon', 69 | 'VoicemailIcon', 70 | 'deepskyblue', 71 | ); 72 | // Basic Voicemail Channel Settings 73 | VoiceMailChannel.templates.TaskListItem.firstLine = (task) => `${task.queueName}: ${task.attributes.name}`; 74 | VoiceMailChannel.templates.TaskCanvasHeader.title = (task) => `${task.queueName}: ${task.attributes.name}`; 75 | VoiceMailChannel.templates.IncomingTaskCanvas.firstLine = (task) => task.queueName; 76 | // Lead Channel Icon 77 | VoiceMailChannel.icons.active = ; 78 | VoiceMailChannel.icons.list = ; 79 | VoiceMailChannel.icons.main = ; 80 | // Register Lead Channel 81 | flex.TaskChannels.register(VoiceMailChannel); 82 | 83 | flex.TaskInfoPanel.Content.replace(, { 84 | sortOrder: -1, 85 | if: (props) => props.task.attributes.taskType === 'voicemail', 86 | }); 87 | } 88 | 89 | /** 90 | * Registers the plugin reducers 91 | * 92 | * @param manager { Flex.Manager } 93 | */ 94 | registerReducers(manager) { 95 | if (!manager.store.addReducer) { 96 | // eslint: disable-next-line 97 | console.error(`You need FlexUI > 1.9.0 to use built-in redux; you are currently on ${VERSION}`); 98 | return; 99 | } 100 | 101 | // add the reducers to the manager store 102 | manager.store.addReducer(namespace, reducers); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/components/callback/CallbackComponent.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as Flex from '@twilio/flex-ui'; 3 | import moment from 'moment'; 4 | import 'moment-timezone'; 5 | import Button from '@material-ui/core/Button'; 6 | import Tooltip from '@material-ui/core/Tooltip'; 7 | import Icon from '@material-ui/core/Icon'; 8 | 9 | import styles from './CallbackStyles'; 10 | import { inqueueUtils } from '../common'; 11 | 12 | export default class CallbackComponent extends React.Component { 13 | static displayName = 'CallbackComponent'; 14 | 15 | constructor(props) { 16 | super(props); 17 | 18 | this.state = {}; 19 | } 20 | 21 | cbCallButtonAccessibility = async (state) => inqueueUtils.callButtonAccessibility(this.props.task, 'callback', state); 22 | 23 | startCall = async () => { 24 | const manager = Flex.Manager.getInstance(); 25 | const activityName = manager.workerClient.activity.name; 26 | if (activityName === 'Offline') { 27 | // eslint-disable-next-line no-alert 28 | alert('Change activity state from "Offline" to place call to contact'); 29 | return; 30 | } 31 | await this.cbCallButtonAccessibility(true); 32 | 33 | const { queueSid, attributes } = this.props.task; 34 | const { to, from } = attributes; 35 | const attr = { 36 | type: 'outbound', 37 | name: `Contact: ${to}`, 38 | phone: to, 39 | }; 40 | await Flex.Actions.invokeAction('StartOutboundCall', { 41 | destination: to, 42 | queueSid, 43 | callerId: from, 44 | taskAttributes: attr, 45 | }); 46 | }; 47 | 48 | startTransfer = async () => { 49 | await this.cbCallButtonAccessibility(false); 50 | 51 | return inqueueUtils.startTransfer(this.props.task); 52 | }; 53 | 54 | render() { 55 | const { attributes } = this.props.task; 56 | const timeReceived = moment(attributes.callTime.time_recvd); 57 | const localTz = moment.tz.guess(); 58 | const localTimeShort = timeReceived.tz(localTz).format('MM-D-YYYY, h:mm:ss a z'); 59 | 60 | // capture taskRetry count - disable button conditionally 61 | const count = attributes.placeCallRetry; 62 | 63 | return ( 64 | 65 |

Contact CallBack Request

66 |

A contact has requested an immediate callback.

67 |

Callback Details

68 |
    69 |
  • 70 |
    71 | Contact Phone: 72 | {attributes.to} 73 |
    74 |
  • 75 |
  •  
  • 76 |
  • 77 |
    78 | Call Reception Information 79 |
    80 |
  • 81 |
  • 82 |
    83 | 91 | 92 | 93 |
    94 |
  • 95 |
  • 96 |
    97 |
    98 |
    99 | 100 | 101 | 102 | info 103 | 104 | 105 | 106 |
    107 |
    108 |
    109 |
  • 110 |
  •  
  • 111 |
112 | 121 |

Not answering? Requeue to try later.

122 | 131 |

 

132 |
133 | ); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/components/callback/CallbackContainer.js: -------------------------------------------------------------------------------- 1 | import { bindActionCreators } from 'redux'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { Actions } from '../../states/ActionInQueueMessagingState'; 5 | import CallbackComponent from './CallbackComponent'; 6 | 7 | const mapStateToProps = (state) => ({ 8 | cbCallButtonAccessibility: state['in-queue-redux'].InQueueMessaging.cbCallButtonAccessibility, 9 | }); 10 | 11 | const mapDispatchToProps = (dispatch) => ({ 12 | cbCallButtonDisable: bindActionCreators(Actions.cbToggleCallButtonDisable, dispatch), 13 | }); 14 | 15 | export default connect(mapStateToProps, mapDispatchToProps)(CallbackComponent); 16 | -------------------------------------------------------------------------------- /src/components/callback/CallbackStyles.js: -------------------------------------------------------------------------------- 1 | export default { 2 | itemWrapper: { 3 | width: '100%', 4 | }, 5 | itemBold: { fontWeight: 'bold' }, 6 | item: { 7 | width: 100, 8 | }, 9 | itemDetail: { 10 | textAlign: 'right', 11 | float: 'right', 12 | marginRight: '5px', 13 | marginTop: '3px', 14 | }, 15 | cbButton: { 16 | width: '100%', 17 | marginBottom: '5px', 18 | fontSize: '10pt', 19 | }, 20 | textCenter: { 21 | textAlign: 'center', 22 | color: 'blue', 23 | }, 24 | info: { position: 'relative', top: '3px' }, 25 | }; 26 | -------------------------------------------------------------------------------- /src/components/callback/index.js: -------------------------------------------------------------------------------- 1 | import CallbackContainer from './CallbackContainer'; 2 | 3 | export default CallbackContainer; 4 | -------------------------------------------------------------------------------- /src/components/common/index.js: -------------------------------------------------------------------------------- 1 | import * as inqueueUtils from './inqueueUtils'; 2 | 3 | export { inqueueUtils }; 4 | -------------------------------------------------------------------------------- /src/components/common/inqueueUtils.js: -------------------------------------------------------------------------------- 1 | import * as Flex from '@twilio/flex-ui'; 2 | 3 | import { buildUrl, http } from '../../helpers'; 4 | 5 | const url = buildUrl('/inqueue-utils'); 6 | 7 | export const callButtonAccessibility = async (task, type, state) => { 8 | const { taskSid, attributes } = task; 9 | const data = { 10 | mode: 'UiPlugin', 11 | type, 12 | Token: Flex.Manager.getInstance().user.token, 13 | taskSid, 14 | attributes, 15 | state, 16 | }; 17 | 18 | return http.post(url, data, { 19 | noJson: true, 20 | verbose: true, 21 | title: 'cbUiPlugin web service', 22 | }); 23 | }; 24 | 25 | export const startTransfer = async (task) => { 26 | const { taskSid, attributes, workflowSid, queueName } = task; 27 | const data = { 28 | mode: 'requeueTasks', 29 | type: 'callback', 30 | Token: Flex.Manager.getInstance().user.token, 31 | taskSid, 32 | attributes, 33 | workflowSid, 34 | queueName, 35 | state: false, 36 | }; 37 | 38 | return http.post(url, data, { verbose: true, title: 'Requeue web service' }); 39 | }; 40 | 41 | export const deleteResource = async (task) => { 42 | const { taskSid, workflowSid, queueName, attributes } = task; 43 | const { recordingSid, transcriptionSid } = attributes; 44 | const data = { 45 | mode: 'deleteRecordResources', 46 | taskSid, 47 | recordingSid, 48 | transcriptionSid, 49 | Token: Flex.Manager.getInstance().user.token, 50 | attributes, 51 | workflowSid, 52 | queueName, 53 | }; 54 | 55 | return http.post(url, data, { 56 | verbose: true, 57 | title: 'Delete resource web service', 58 | }); 59 | }; 60 | -------------------------------------------------------------------------------- /src/components/index.js: -------------------------------------------------------------------------------- 1 | export { default as CallbackComponent } from './callback'; 2 | export { default as VoicemailComponent } from './voicemail'; 3 | -------------------------------------------------------------------------------- /src/components/voicemail/VoicemailComponent.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as Flex from '@twilio/flex-ui'; 3 | import moment from 'moment'; 4 | import 'moment-timezone'; 5 | import Button from '@material-ui/core/Button'; 6 | import TextField from '@material-ui/core/TextField'; 7 | import Tooltip from '@material-ui/core/Tooltip'; 8 | import Icon from '@material-ui/core/Icon'; 9 | 10 | import styles from './VoicemailStyles'; 11 | import { inqueueUtils } from '../common'; 12 | 13 | export default class VoicemailComponent extends React.Component { 14 | static displayName = 'VoicemailComponent'; 15 | 16 | constructor(props) { 17 | super(props); 18 | 19 | this.state = {}; 20 | } 21 | 22 | /* 23 | * create outbound call from Flex using Actions API 'StartOutboundCall' 24 | * 25 | */ 26 | vmCallButtonAccessibility = async (state) => 27 | inqueueUtils.callButtonAccessibility(this.props.task, 'voicemail', state); 28 | 29 | startTransfer = async () => inqueueUtils.startTransfer(this.props.task); 30 | 31 | // web service call to delete the call recording/transcript 32 | deleteResources = async () => inqueueUtils.deleteResource(this.props.task); 33 | 34 | startCall = async () => { 35 | const manager = Flex.Manager.getInstance(); 36 | const activityName = manager.workerClient.activity.name; 37 | if (activityName === 'Offline') { 38 | // eslint-disable-next-line no-alert 39 | alert('Change activity state from "Offline" to place call to contact'); 40 | return; 41 | } 42 | 43 | await this.vmCallButtonAccessibility(true); 44 | 45 | const { queueSid, attributes } = this.props.task; 46 | const { to, from } = attributes; 47 | 48 | // place outbound call using Flex DialPad API 49 | await Flex.Actions.invokeAction('StartOutboundCall', { 50 | destination: to, 51 | queueSid, 52 | callerId: from, 53 | taskAttributes: { 54 | type: 'outbound', 55 | name: `Contact: ${to}`, 56 | phone: to, 57 | }, 58 | }); 59 | }; 60 | 61 | render() { 62 | const { attributes } = this.props.task; 63 | const timeReceived = moment(attributes.callTime.time_recvd); 64 | const localTz = moment.tz.guess(); 65 | const localTimeShort = timeReceived.tz(localTz).format('MM-D-YYYY, h:mm:ss a z'); 66 | 67 | // set recordingURL/transcriptionText for record deletion events 68 | const markedDeleted = attributes.hasOwnProperty('markDeleted'); 69 | const transcriptText = markedDeleted ? 'No call transcription captured' : attributes.transcriptionText; 70 | const recordUrl = markedDeleted ? '' : attributes.recordingUrl; 71 | const count = attributes.placeCallRetry; 72 | 73 | return ( 74 | 75 |

Contact Voicemail

76 |

This contact has left a voicemail that requires attention.

77 | 78 |
79 |
81 |
82 |

Voicemail Transcript

83 | 96 |
97 |

Voicemail Details

98 |
    99 |
  • 100 |
    101 | Contact Phone: 102 | {attributes.to} 103 |
    104 |
  • 105 |
  •  
  • 106 |
  • 107 |
    108 | Call Reception Information 109 |
    110 |
  • 111 |
  • 112 |
    113 | 121 | 122 | 123 |
    124 |
  • 125 |
  • 126 |
    127 |
    128 |
    129 | 130 | 131 | 132 | info 133 | 134 | 135 | 136 |
    137 |
    138 |
    139 |
  • 140 |
  •  
  • 141 |
142 | 151 | 152 |

Not answering? Requeue to retry later.

153 | 162 |

Upon successful contact, delete the recording resources.

163 | 172 | 173 |

 

174 |
175 | ); 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/components/voicemail/VoicemailContainer.js: -------------------------------------------------------------------------------- 1 | import { bindActionCreators } from 'redux'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { Actions } from '../../states/ActionInQueueMessagingState'; 5 | import VoicemailComponent from './VoicemailComponent'; 6 | 7 | const mapStateToProps = (state) => ({ 8 | vmCallButtonAccessibility: state['in-queue-redux'].InQueueMessaging.vmCallButtonAccessibility, 9 | vmRecordButtonAccessibility: state['in-queue-redux'].InQueueMessaging.vmRecordButtonAccessibility, 10 | }); 11 | 12 | const mapDispatchToProps = (dispatch) => ({ 13 | vmCallButtonDisable: bindActionCreators(Actions.vmToggleCallButtonDisable, dispatch), 14 | vmRecordButtonDisable: bindActionCreators(Actions.vmToggleRecordButtonDisable, dispatch), 15 | }); 16 | 17 | export default connect(mapStateToProps, mapDispatchToProps)(VoicemailComponent); 18 | -------------------------------------------------------------------------------- /src/components/voicemail/VoicemailStyles.js: -------------------------------------------------------------------------------- 1 | export default { 2 | itemWrapper: { 3 | width: '100%', 4 | }, 5 | itemBold: { fontWeight: 'bold' }, 6 | item: { 7 | width: 150, 8 | }, 9 | itemDetail: { 10 | textAlign: 'right', 11 | float: 'right', 12 | marginRight: '5px', 13 | marginTop: '3px', 14 | }, 15 | cbButton: { 16 | width: '100%', 17 | marginBottom: '5px', 18 | fontSize: '9pt', 19 | }, 20 | audioWrapper: { 21 | width: '100%', 22 | }, 23 | transcriptWrapper: { 24 | width: '100%', 25 | marginBottom: 10, 26 | }, 27 | h4Title: { 28 | fontWeight: 'bold', 29 | }, 30 | transcript: { 31 | width: '90%', 32 | marginRight: 10, 33 | height: 50, 34 | paddingLeft: 10, 35 | paddingRight: 10, 36 | paddingTop: 10, 37 | paddingBottom: 10, 38 | border: 'solid 1px #999999', 39 | }, 40 | audio: { 41 | width: '100%', 42 | marginTop: 10, 43 | marginBottom: 10, 44 | }, 45 | textCenter: { 46 | textAlign: 'center', 47 | color: 'blue', 48 | }, 49 | textAlert: { 50 | color: 'red', 51 | textAlign: 'center', 52 | }, 53 | test: { 54 | fontSize: '9pt', 55 | }, 56 | info: { position: 'relative', top: '3px' }, 57 | }; 58 | -------------------------------------------------------------------------------- /src/components/voicemail/index.js: -------------------------------------------------------------------------------- 1 | import VoicemailContainer from './VoicemailContainer'; 2 | 3 | export default VoicemailContainer; 4 | -------------------------------------------------------------------------------- /src/helpers/http.js: -------------------------------------------------------------------------------- 1 | import { logger } from '.'; 2 | 3 | /** 4 | * makes a post request 5 | * @param url the url to post to 6 | * @param requestInfo the request info 7 | * @param options the options 8 | * @param options.noJson if set, response is not parsed 9 | * @param options.verbose if set, will wrap the response in a verbose log 10 | * @param options.title if title to use with the verbose 11 | * @return {Promise} 12 | */ 13 | const _post = async (url, requestInfo, options) => { 14 | options = options || {}; 15 | requestInfo.method = 'POST'; 16 | 17 | const promise = fetch(url, requestInfo).then((resp) => { 18 | if (options.noJson) { 19 | return resp; 20 | } 21 | return resp.json(); 22 | }); 23 | if (options.verbose && options.title) { 24 | promise 25 | .then(() => { 26 | logger.info(`==== ${options.title} was successful ====`); 27 | }) 28 | .catch((error) => { 29 | logger.error(`${options.title} failed:`, error); 30 | }); 31 | } 32 | 33 | return promise; 34 | }; 35 | /** 36 | * makes a post request 37 | * @param url the url to post to 38 | * @param data the data 39 | * @param options the options 40 | * @param options.noJson if set, response is not parsed 41 | * @param options.verbose if set, will wrap the response in a verbose log 42 | * @param options.title if title to use with the verbose 43 | * @return {Promise} 44 | */ 45 | export const post = async (url, data = {}, options) => { 46 | return _post( 47 | url, 48 | { 49 | headers: { 50 | 'Content-Type': 'application/json', 51 | }, 52 | body: JSON.stringify(data), 53 | }, 54 | options, 55 | ); 56 | }; 57 | 58 | /** 59 | * makes a post request 60 | * @param url the url to post to 61 | * @param data the data 62 | * @param options the options 63 | * @param options.noJson if set, response is not parsed 64 | * @return {Promise} 65 | */ 66 | export const postUrlEncoded = async (url, data, options) => { 67 | return _post( 68 | url, 69 | { 70 | headers: { 71 | 'Content-Type': 'application/x-www-form-urlencoded', 72 | }, 73 | body: JSON.stringify(data), 74 | }, 75 | options, 76 | ); 77 | }; 78 | -------------------------------------------------------------------------------- /src/helpers/index.js: -------------------------------------------------------------------------------- 1 | import * as logger from './logger'; 2 | import * as http from './http'; 3 | 4 | export { buildUrl } from './urlHelper'; 5 | 6 | export { logger, http }; 7 | -------------------------------------------------------------------------------- /src/helpers/logger.js: -------------------------------------------------------------------------------- 1 | import * as util from 'util'; 2 | 3 | /** 4 | * Logs the event 5 | */ 6 | const log = (level, ...args) => { 7 | // eslint-disable-next-line prefer-spread,no-console 8 | console[level](util.format.apply(util, args)); 9 | }; 10 | 11 | export const debug = (...args) => log('debug', ...args); 12 | 13 | export const info = (...args) => log('log', ...args); 14 | 15 | export const error = (...args) => log('error', ...args); 16 | -------------------------------------------------------------------------------- /src/helpers/urlHelper.js: -------------------------------------------------------------------------------- 1 | import urlJoin from 'url-join'; 2 | 3 | export const buildUrl = (...uris) => { 4 | const baseUrl = process.env.REACT_APP_SERVICE_BASE_URL; 5 | return urlJoin(baseUrl, ...uris); 6 | }; 7 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import * as FlexPlugin from 'flex-plugin'; 2 | 3 | import InQueueMessagingPlugin from './InQueueMessagingPlugin'; 4 | 5 | FlexPlugin.loadPlugin(InQueueMessagingPlugin); 6 | -------------------------------------------------------------------------------- /src/states/ActionInQueueMessagingState.js: -------------------------------------------------------------------------------- 1 | /* 2 | *This file declares the redux store: 3 | *1. Action method constants 4 | *2. Initial state of the store 5 | *3. ACTIONS (methods) 6 | *4. Defines the reducer logic 7 | * 8 | *Synopsis: This file creates the application "store" (i.e. State) that 9 | *is shared across components, or "state" data that is used by components as they 10 | *are loaded or unloaded. 11 | * 12 | *The redux stored allows components to "resume" their state 13 | */ 14 | 15 | // define the action methods identifiers (constants) 16 | const ACTION_CB_CALL_BTN_ACCESSIBILITY = 'CB_CALL_BTN_ACCESSIBILITY'; 17 | const ACTION_VM_CALL_BTN_ACCESSIBILITY = 'VM_CALL_BTN_ACCESSIBILITY'; 18 | const ACTION_VM_RECORD_BTN_ACCESSIBILITY = 'VM_RECORD_BTN_ACCESSIBILITY'; 19 | 20 | // define the initial state values of the REDUX store 21 | const initialState = { 22 | cbCallButtonAccessibility: false, 23 | vmCallButtonAccessibility: false, 24 | vmRecordButtonAccessibility: true, 25 | }; 26 | 27 | // declare the actions (methods) for acting on the reducer 28 | export class Actions { 29 | // static dismissBar = () => ({ type: ACTION_DISMISS_BAR }); 30 | static cbToggleCallButtonDisable = (value) => ({ 31 | type: ACTION_CB_CALL_BTN_ACCESSIBILITY, 32 | value, 33 | }); 34 | 35 | static vmToggleCallButtonDisable = (value) => ({ 36 | type: ACTION_VM_CALL_BTN_ACCESSIBILITY, 37 | value, 38 | }); 39 | 40 | static vmToggleRecordButtonDisable = (value) => ({ 41 | type: ACTION_VM_RECORD_BTN_ACCESSIBILITY, 42 | value, 43 | }); 44 | } 45 | 46 | // define the reducer logic (updates to the application state) 47 | export function reduce(state = initialState, action) { 48 | /* 49 | * console.log("===== in my reducer ====="); 50 | * console.log(action, state); 51 | */ 52 | 53 | switch (action.type) { 54 | case ACTION_CB_CALL_BTN_ACCESSIBILITY: { 55 | // amend the updated store property based in updated value received 56 | return { 57 | ...state, 58 | cbCallButtonAccessibility: action.value, 59 | }; 60 | } 61 | case ACTION_VM_CALL_BTN_ACCESSIBILITY: { 62 | // amend the updated store property based in updated value received 63 | return { 64 | ...state, 65 | vmCallButtonAccessibility: action.value, 66 | }; 67 | } 68 | case ACTION_VM_RECORD_BTN_ACCESSIBILITY: { 69 | // amend the updated store property based in updated value received 70 | return { 71 | ...state, 72 | vmRecordButtonAccessibility: action.value, 73 | }; 74 | } 75 | default: 76 | return state; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/states/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | 3 | // define the Redux reducers 4 | import { reduce as InQueueMessagingReducer } from './ActionInQueueMessagingState'; 5 | 6 | // Register your redux store under a unique namespace 7 | export const namespace = 'in-queue-redux'; 8 | 9 | /* 10 | * Combine the reducers 11 | * define redux store identifier (InQueueMessaging) 12 | * Store: state[]..{state object} 13 | */ 14 | export default combineReducers({ 15 | InQueueMessaging: InQueueMessagingReducer, 16 | }); 17 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = (config, { isProd, isDev, isTest }) => { 2 | /** 3 | * Customize the webpack by modifying the config object. 4 | * Consult https://webpack.js.org/configuration for more information 5 | */ 6 | 7 | return config; 8 | } 9 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | module.exports = (config, { isProd, isDev, isTest }) => { 2 | /** 3 | * Customize the webpack dev-server by modifying the config object. 4 | * Consult https://webpack.js.org/configuration/dev-server for more information. 5 | */ 6 | 7 | return config; 8 | } 9 | --------------------------------------------------------------------------------