├── .env.example ├── .github └── workflows │ └── flex-deploy.yml ├── .gitignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── plugin-flex-did ├── .gitignore ├── README.md ├── babel.config.js ├── jest.config.js ├── package-lock.json ├── package.json ├── public │ └── appConfig.example.js ├── src │ ├── FlexDidPlugin.js │ ├── components │ │ ├── AgentNumber │ │ │ └── AgentNumber.jsx │ │ └── __tests__ │ │ │ └── AgentNumber.spec.jsx │ ├── index.js │ └── setupTests.js ├── webpack.config.js └── webpack.dev.js └── setup-scripts ├── buyNumbers.js ├── studioSetup.js ├── updateNumbers.js └── workflowSetup.js /.env.example: -------------------------------------------------------------------------------- 1 | ACCOUNT_SID=ACXXXXX 2 | ACCOUNT_SECRET=XXXXXX -------------------------------------------------------------------------------- /.github/workflows/flex-deploy.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch 3 | 4 | jobs: 5 | deployFlexPlugin: 6 | runs-on: ubuntu-latest 7 | 8 | strategy: 9 | matrix: 10 | node-version: [12.x] 11 | steps: 12 | - name: Setup 13 | uses: actions/checkout@v2 14 | - name: Use Node.js ${{ matrix.node-version }} 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: ${{ matrix.node-version }} 18 | - run: npm install twilio-cli -g 19 | - run: twilio plugins:install @twilio-labs/plugin-flex@beta 20 | - run: npm install 21 | working-directory: ./plugin-flex-did 22 | - run: cp appConfig.example.js appConfig.js 23 | working-directory: ./plugin-flex-did/public 24 | env: 25 | TWILIO_ACCOUNT_SID: ${{ secrets.ACCOUNT_SID }} 26 | TWILIO_AUTH_TOKEN: ${{ secrets.ACCOUNT_SECRET }} 27 | - run: twilio flex:plugins:deploy --changelog "Deploying flex-did" 28 | working-directory: ./plugin-flex-did 29 | env: 30 | TWILIO_ACCOUNT_SID: ${{ secrets.ACCOUNT_SID }} 31 | TWILIO_AUTH_TOKEN: ${{ secrets.ACCOUNT_SECRET }} 32 | - run: twilio flex:plugins:release --plugin plugin-flex-did@0.0.1 --name "Flex DID" --description "Provides direct inward dial functionality." 33 | working-directory: ./plugin-flex-did 34 | env: 35 | TWILIO_ACCOUNT_SID: ${{ secrets.ACCOUNT_SID }} 36 | TWILIO_AUTH_TOKEN: ${{ secrets.ACCOUNT_SECRET }} 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | /node_modules -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Charlie Weems 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 | # Flex Direct Inward Dial 2 | 3 | ![Screen Shot 2021-01-10 at 10 59 37 PM](https://user-images.githubusercontent.com/1418949/104153583-9468df00-5397-11eb-965f-43e41040917f.png) 4 | 5 | Creates a Direct Inward Dial (DID) setup for Twilio Flex. Each agent can be assigned a number, and calls to that number will go directly to that agent. Outbound calls will come from that agent's assigned number. 6 | 7 | ![Screen Shot 2021-01-10 at 11 02 48 PM](https://user-images.githubusercontent.com/1418949/104153867-4e604b00-5398-11eb-8bbc-d3cb66236532.png) 8 | 9 | ## Setup 10 | 11 | Flex DID configures inbound phone numbers with a Studio Flow and TaskRouter Workflow that matches tasks to workers using a shared `phone_number` attribute. 12 | 13 | Not supported or maintained by Twilio. MIT License. 14 | 15 | ### Initial setup 16 | 17 | ```shell 18 | $ git clone https://github.com/cweems/flex-did.git 19 | $ cd flex-did 20 | $ npm install 21 | $ cp .env.example .env 22 | 23 | # Add your Twilio API Key and Secret to .env 24 | ``` 25 | 26 | ### Twilio Account Configuration 27 | 28 | Twilio Account configuration in one command: 29 | 30 | ```shell 31 | $ npm run flex-did-setup-all 32 | ``` 33 | 34 | Run each command separately: 35 | 36 | ```shell 37 | $ buy-numbers 38 | $ workflow-setup 39 | $ studio-setup 40 | $ update-numbers 41 | ``` 42 | 43 | ### Agent Attribute / SSO Configuration 44 | 45 | Add a `phone_number` attribute to one of your TaskRouter workers with an e.164 phone number. Or, configure your SSO system to provide the `phone_number` attribute when an agent logs in. 46 | 47 | ### Twilio Flex Plugin Testing and Deployment 48 | 49 | From the `flex-did` directory, run the following to start your dev environment: 50 | 51 | ```shell 52 | $ cd plugin-flex-did 53 | $ npm install 54 | $ twilio flex:plugins:start 55 | ``` 56 | 57 | To deploy the plugin, run: 58 | 59 | ```shell 60 | $ twilio flex:plugins:deploy --changelog "Deploying flex-did" 61 | 62 | $ twilio flex:plugins:release --plugin plugin-flex-did@0.0.1 --name "Flex DID" --description "Provides direct inward dial functionality." 63 | ``` 64 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flex-did", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "asap": { 8 | "version": "2.0.6", 9 | "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", 10 | "integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=" 11 | }, 12 | "axios": { 13 | "version": "0.21.1", 14 | "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", 15 | "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==", 16 | "requires": { 17 | "follow-redirects": "^1.10.0" 18 | } 19 | }, 20 | "buffer-equal-constant-time": { 21 | "version": "1.0.1", 22 | "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", 23 | "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" 24 | }, 25 | "dayjs": { 26 | "version": "1.10.2", 27 | "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.10.2.tgz", 28 | "integrity": "sha512-h/YtykNNTR8Qgtd1Fxl5J1/SFP1b7SOk/M1P+Re+bCdFMV0IMkuKNgHPN7rlvvuhfw24w0LX78iYKt4YmePJNQ==" 29 | }, 30 | "dotenv": { 31 | "version": "8.2.0", 32 | "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz", 33 | "integrity": "sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==" 34 | }, 35 | "ecdsa-sig-formatter": { 36 | "version": "1.0.11", 37 | "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", 38 | "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", 39 | "requires": { 40 | "safe-buffer": "^5.0.1" 41 | } 42 | }, 43 | "follow-redirects": { 44 | "version": "1.13.1", 45 | "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.1.tgz", 46 | "integrity": "sha512-SSG5xmZh1mkPGyKzjZP8zLjltIfpW32Y5QpdNJyjcfGxK3qo3NDDkZOZSFiGn1A6SclQxY9GzEwAHQ3dmYRWpg==" 47 | }, 48 | "jsonwebtoken": { 49 | "version": "8.5.1", 50 | "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz", 51 | "integrity": "sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==", 52 | "requires": { 53 | "jws": "^3.2.2", 54 | "lodash.includes": "^4.3.0", 55 | "lodash.isboolean": "^3.0.3", 56 | "lodash.isinteger": "^4.0.4", 57 | "lodash.isnumber": "^3.0.3", 58 | "lodash.isplainobject": "^4.0.6", 59 | "lodash.isstring": "^4.0.1", 60 | "lodash.once": "^4.0.0", 61 | "ms": "^2.1.1", 62 | "semver": "^5.6.0" 63 | } 64 | }, 65 | "jwa": { 66 | "version": "1.4.1", 67 | "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", 68 | "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", 69 | "requires": { 70 | "buffer-equal-constant-time": "1.0.1", 71 | "ecdsa-sig-formatter": "1.0.11", 72 | "safe-buffer": "^5.0.1" 73 | } 74 | }, 75 | "jws": { 76 | "version": "3.2.2", 77 | "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", 78 | "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", 79 | "requires": { 80 | "jwa": "^1.4.1", 81 | "safe-buffer": "^5.0.1" 82 | } 83 | }, 84 | "lodash": { 85 | "version": "4.17.20", 86 | "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", 87 | "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==" 88 | }, 89 | "lodash.includes": { 90 | "version": "4.3.0", 91 | "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", 92 | "integrity": "sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=" 93 | }, 94 | "lodash.isboolean": { 95 | "version": "3.0.3", 96 | "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", 97 | "integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=" 98 | }, 99 | "lodash.isinteger": { 100 | "version": "4.0.4", 101 | "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", 102 | "integrity": "sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=" 103 | }, 104 | "lodash.isnumber": { 105 | "version": "3.0.3", 106 | "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", 107 | "integrity": "sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=" 108 | }, 109 | "lodash.isplainobject": { 110 | "version": "4.0.6", 111 | "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", 112 | "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=" 113 | }, 114 | "lodash.isstring": { 115 | "version": "4.0.1", 116 | "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", 117 | "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" 118 | }, 119 | "lodash.once": { 120 | "version": "4.1.1", 121 | "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", 122 | "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" 123 | }, 124 | "ms": { 125 | "version": "2.1.3", 126 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 127 | "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" 128 | }, 129 | "pop-iterate": { 130 | "version": "1.0.1", 131 | "resolved": "https://registry.npmjs.org/pop-iterate/-/pop-iterate-1.0.1.tgz", 132 | "integrity": "sha1-zqz9q0q/NT16DyqqLB/Hs/lBO6M=" 133 | }, 134 | "q": { 135 | "version": "2.0.3", 136 | "resolved": "https://registry.npmjs.org/q/-/q-2.0.3.tgz", 137 | "integrity": "sha1-dbjbAlWhpa+C9Yw/Oqoe/sfQ0TQ=", 138 | "requires": { 139 | "asap": "^2.0.0", 140 | "pop-iterate": "^1.0.1", 141 | "weak-map": "^1.0.5" 142 | } 143 | }, 144 | "qs": { 145 | "version": "6.9.4", 146 | "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.4.tgz", 147 | "integrity": "sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ==" 148 | }, 149 | "querystringify": { 150 | "version": "2.2.0", 151 | "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", 152 | "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" 153 | }, 154 | "requires-port": { 155 | "version": "1.0.0", 156 | "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", 157 | "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=" 158 | }, 159 | "rootpath": { 160 | "version": "0.1.2", 161 | "resolved": "https://registry.npmjs.org/rootpath/-/rootpath-0.1.2.tgz", 162 | "integrity": "sha1-Wzeah9ypBum5HWkKWZQ5vvJn6ms=" 163 | }, 164 | "safe-buffer": { 165 | "version": "5.2.1", 166 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", 167 | "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" 168 | }, 169 | "scmp": { 170 | "version": "2.1.0", 171 | "resolved": "https://registry.npmjs.org/scmp/-/scmp-2.1.0.tgz", 172 | "integrity": "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==" 173 | }, 174 | "semver": { 175 | "version": "5.7.1", 176 | "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", 177 | "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" 178 | }, 179 | "twilio": { 180 | "version": "3.54.2", 181 | "resolved": "https://registry.npmjs.org/twilio/-/twilio-3.54.2.tgz", 182 | "integrity": "sha512-Hr3mb8/2yLaVIbcSLWtymPzt42atExlBU5eydI6oKAhAZiTuER4LyDsqKcJ4PBFeZDFzG7Qu0yLZ8bYp8ydV4w==", 183 | "requires": { 184 | "axios": "^0.21.1", 185 | "dayjs": "^1.8.29", 186 | "jsonwebtoken": "^8.5.1", 187 | "lodash": "^4.17.19", 188 | "q": "2.0.x", 189 | "qs": "^6.9.4", 190 | "rootpath": "^0.1.2", 191 | "scmp": "^2.1.0", 192 | "url-parse": "^1.4.7", 193 | "xmlbuilder": "^13.0.2" 194 | } 195 | }, 196 | "url-parse": { 197 | "version": "1.4.7", 198 | "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.4.7.tgz", 199 | "integrity": "sha512-d3uaVyzDB9tQoSXFvuSUNFibTd9zxd2bkVrDRvF5TmvWWQwqE4lgYJ5m+x1DbecWkw+LK4RNl2CU1hHuOKPVlg==", 200 | "requires": { 201 | "querystringify": "^2.1.1", 202 | "requires-port": "^1.0.0" 203 | } 204 | }, 205 | "weak-map": { 206 | "version": "1.0.5", 207 | "resolved": "https://registry.npmjs.org/weak-map/-/weak-map-1.0.5.tgz", 208 | "integrity": "sha1-eWkVhNmGB/UHC9O3CkDmuyLkAes=" 209 | }, 210 | "xmlbuilder": { 211 | "version": "13.0.2", 212 | "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-13.0.2.tgz", 213 | "integrity": "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==" 214 | } 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flex-did", 3 | "version": "1.0.0", 4 | "description": "Configure direct inward dialing for Twilio Flex", 5 | "main": "buynumber.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "flex-did-setup-all": "buy-numbers && workflow-setup && studio-setup && update-numbers" 9 | }, 10 | "bin": { 11 | "buy-numbers": "./setup-scripts/buyNumbers.js", 12 | "studio-setup": "./setup-scripts/studioSetup.js", 13 | "update-numbers": "./setup-scripts/updateNumbers.js", 14 | "workflow-setup": "./setup-scripts/workflowSetup.js" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/cweems/flex-did.git" 19 | }, 20 | "keywords": [ 21 | "twilio", 22 | "flex", 23 | "did", 24 | "direct dial", 25 | "direct inward dial", 26 | "call center" 27 | ], 28 | "author": "Charlie Weems", 29 | "license": "ISC", 30 | "bugs": { 31 | "url": "https://github.com/cweems/flex-did/issues" 32 | }, 33 | "homepage": "https://github.com/cweems/flex-did#readme", 34 | "dependencies": { 35 | "dotenv": "^8.2.0", 36 | "twilio": "^3.54.2" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /plugin-flex-did/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | # Flex related ignore 64 | appConfig.js 65 | pluginsService.js 66 | build/ 67 | -------------------------------------------------------------------------------- /plugin-flex-did/README.md: -------------------------------------------------------------------------------- 1 | # Flex DID Plugin 2 | 3 | The Flex DID plugin provides two features: 4 | 5 | - Agents can see their assigned phone number (`worker.phone_number`) 6 | - Agents can initiate calls from their assigned phone number 7 | 8 | ![Screen Shot 2021-01-10 at 11 02 48 PM](https://user-images.githubusercontent.com/1418949/104153867-4e604b00-5398-11eb-8bbc-d3cb66236532.png) 9 | 10 | ## Setup 11 | 12 | Make sure you have [Node.js](https://nodejs.org) as well as [`npm`](https://npmjs.com) installed. 13 | 14 | Afterwards, install the dependencies by running `npm install`: 15 | 16 | ```bash 17 | $ npm install 18 | ``` 19 | 20 | ## Development 21 | 22 | In order to develop locally, you can use the Webpack Dev Server by running: 23 | 24 | ```bash 25 | $ twilio flex:plugins:start 26 | ``` 27 | 28 | This will automatically start up the Webpack Dev Server and open the browser for you. Your app will run on `http://localhost:3000`. If you want to change that you can do this by setting the `PORT` environment variable: 29 | 30 | ```bash 31 | PORT=3001 npm start 32 | ``` 33 | 34 | When you make changes to your code, the browser window will be automatically refreshed. 35 | 36 | ## Deploy 37 | 38 | When you are ready to deploy your plugin, in your terminal run: 39 | 40 | ```bash 41 | $ twilio flex:plugins:deploy --changelog "Deploying flex-did" 42 | 43 | $ twilio flex:plugins:release --plugin plugin-flex-did@0.0.1 --name "Flex DID" --description "Provides direct inward dial functionality." 44 | ``` 45 | 46 | For more details on deploying your plugin, refer to the [deploying your plugin guide](https://www.twilio.com/docs/flex/plugins#deploying-your-plugin). 47 | 48 | Note: Common packages like `React`, `ReactDOM`, `Redux` and `ReactRedux` are not bundled with the build because they are treated as external dependencies so the plugin will depend on Flex to provide them globally. 49 | -------------------------------------------------------------------------------- /plugin-flex-did/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@babel/preset-env', 4 | '@babel/preset-react', 5 | ], 6 | }; -------------------------------------------------------------------------------- /plugin-flex-did/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['./src/components/__tests__/'], 3 | setupFiles: ["/src/setupTests.js"] 4 | } 5 | -------------------------------------------------------------------------------- /plugin-flex-did/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "plugin-flex-did", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "postinstall": "flex-plugin pre-script-check", 7 | "test": "jest" 8 | }, 9 | "dependencies": { 10 | "flex-plugin-scripts": "^4.3.5-beta.0", 11 | "react": "16.5.2", 12 | "react-dom": "16.5.2" 13 | }, 14 | "devDependencies": { 15 | "@babel/core": "^7.12.10", 16 | "@babel/preset-env": "^7.12.11", 17 | "@babel/preset-react": "^7.12.10", 18 | "@twilio/flex-ui": "^1", 19 | "babel-jest": "^26.6.3", 20 | "enzyme-adapter-react-16": "^1.15.5", 21 | "jest": "^26.6.3", 22 | "react-test-renderer": "^17.0.1" 23 | } 24 | } -------------------------------------------------------------------------------- /plugin-flex-did/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 | -------------------------------------------------------------------------------- /plugin-flex-did/src/FlexDidPlugin.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { VERSION } from '@twilio/flex-ui'; 3 | import { FlexPlugin } from 'flex-plugin'; 4 | 5 | import AgentNumber from './components/AgentNumber/AgentNumber'; 6 | 7 | const PLUGIN_NAME = 'FlexDidPlugin'; 8 | 9 | export default class FlexDidPlugin extends FlexPlugin { 10 | constructor() { 11 | super(PLUGIN_NAME); 12 | } 13 | 14 | /** 15 | * This code is run when your plugin is being started 16 | * Use this to modify any UI components or attach to the actions framework 17 | * 18 | * @param flex { typeof import('@twilio/flex-ui') } 19 | * @param manager { import('@twilio/flex-ui').Manager } 20 | */ 21 | init(flex, manager) { 22 | 23 | flex.Actions.replaceAction("StartOutboundCall", (payload, original) => { 24 | let newPayload = payload 25 | 26 | if (manager.workerClient.attributes.hasOwnProperty('phone_number') && 27 | manager.workerClient.attributes.phone_number !== "") { 28 | 29 | newPayload.callerId = manager.workerClient.attributes.phone_number 30 | } 31 | 32 | original(newPayload) 33 | }); 34 | 35 | flex.MainHeader.Content.add( 36 | , { 40 | sortOrder: -1, 41 | align: "end" 42 | } 43 | ); 44 | 45 | flex.OutboundDialerPanel.Content.add( 46 | 50 | ); 51 | 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /plugin-flex-did/src/components/AgentNumber/AgentNumber.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default class AgentNumber extends React.Component { 4 | formatNumber(phoneNumber) { 5 | if (phoneNumber.substring(0, 1) === "+") { 6 | phoneNumber = phoneNumber.substring(1); 7 | } 8 | 9 | if (phoneNumber.substring(0, 1) === "1") { 10 | phoneNumber = phoneNumber.substring(1); 11 | } 12 | 13 | let areaCode = phoneNumber.substring(0, 3); 14 | let exchangeCode = phoneNumber.substring(3, 6); 15 | let lineNumber = phoneNumber.substring(6, 10); 16 | 17 | return `(${areaCode}) ${exchangeCode}-${lineNumber}`; 18 | } 19 | 20 | render() { 21 | let phoneNumber; 22 | if (this.props.phoneNumber) { 23 | phoneNumber = this.formatNumber(this.props.phoneNumber); 24 | } else { 25 | phoneNumber = "Not Set"; 26 | } 27 | 28 | return ( 29 |

30 | My Number: {phoneNumber} 31 |

32 | ); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /plugin-flex-did/src/components/__tests__/AgentNumber.spec.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { shallow } from "enzyme"; 3 | import AgentNumber from "../AgentNumber/AgentNumber.jsx"; 4 | 5 | describe("AgentNumber", () => { 6 | it("should render the number in US domestic format if provided", () => { 7 | const wrapper = shallow(); 8 | expect(wrapper.render().text()).toMatch("My Number: (234) 567-8900"); 9 | }); 10 | 11 | it("should tell the user their number is not set if not provided", () => { 12 | const wrapper = shallow(); 13 | expect(wrapper.render().text()).toMatch("My Number: Not Set"); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /plugin-flex-did/src/index.js: -------------------------------------------------------------------------------- 1 | import * as FlexPlugin from 'flex-plugin'; 2 | import FlexDidPlugin from './FlexDidPlugin'; 3 | 4 | FlexPlugin.loadPlugin(FlexDidPlugin); 5 | -------------------------------------------------------------------------------- /plugin-flex-did/src/setupTests.js: -------------------------------------------------------------------------------- 1 | import { configure } from "enzyme"; 2 | import Adapter from "enzyme-adapter-react-16"; 3 | configure({ adapter: new Adapter() }); -------------------------------------------------------------------------------- /plugin-flex-did/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 | -------------------------------------------------------------------------------- /plugin-flex-did/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 | -------------------------------------------------------------------------------- /setup-scripts/buyNumbers.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require("dotenv").config(); 4 | 5 | const readline = require("readline"); 6 | const client = require("twilio")( 7 | process.env.ACCOUNT_SID, 8 | process.env.ACCOUNT_SECRET 9 | ); 10 | 11 | const rl = readline.createInterface({ 12 | input: process.stdin, 13 | output: process.stdout 14 | }); 15 | 16 | async function run() { 17 | console.log('\n=== Buy Numbers ===\n') 18 | rl.question("What is the 3-digit area code where you would like to buy numbers? ", (areaCode) => { 19 | rl.question("How many numbers would you like to purchase? Note: trial accounts are limited to one number. ", (quantity) => { 20 | getNumbers(areaCode, parseInt(quantity, 10)) 21 | .then(() => rl.close()) 22 | }); 23 | }); 24 | 25 | rl.on("close", () => { 26 | process.exit(0); 27 | }); 28 | } 29 | 30 | function getNumbers(areaCode, quantity) { 31 | console.log(`Buying ${quantity} numbers in the ${areaCode} area code.`); 32 | 33 | return new Promise((resolve, reject) => { 34 | client 35 | .availablePhoneNumbers("US") 36 | .local.list({ areaCode: areaCode, limit: quantity }) 37 | .then((local) => { 38 | 39 | let promises = []; 40 | local.forEach((l) => { 41 | promises.push(buyNumber(l.phoneNumber)); 42 | }) 43 | 44 | Promise.all(promises).then(() => { 45 | resolve(); 46 | }) 47 | .catch(err => reject(err)) 48 | } 49 | ) 50 | .catch((err) => console.log(err)); 51 | }); 52 | 53 | } 54 | 55 | function buyNumber(number) { 56 | return new Promise((resolve, reject) => { 57 | client.incomingPhoneNumbers 58 | .create({ phoneNumber: number }) 59 | .then((incoming_phone_number) => { 60 | console.log(`Successfully purchased ${incoming_phone_number.phoneNumber}`) 61 | resolve(); 62 | }) 63 | .catch((err) => reject(err)); 64 | }) 65 | } 66 | 67 | run(); 68 | -------------------------------------------------------------------------------- /setup-scripts/studioSetup.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const { waitForDebugger } = require('inspector'); 4 | 5 | require('dotenv').config(); 6 | 7 | const client = require("twilio")( 8 | process.env.ACCOUNT_SID, 9 | process.env.ACCOUNT_SECRET 10 | ); 11 | 12 | function getWorkspaceSid() { 13 | console.log("Getting the Flex TaskRouter Workspace SID.") 14 | return new Promise((resolve, reject) => { 15 | client.taskrouter.workspaces 16 | .list({limit: 20}) 17 | .then(workspaces => { 18 | workspaces.forEach(w => { 19 | if (w.friendlyName === "Flex Task Assignment") { 20 | console.log(`Found Workspace ${w.sid}.`) 21 | resolve(w.sid); 22 | return; 23 | } 24 | }) 25 | 26 | reject('Could not find Flex Task Assignment workspace. Did you change the friendly name?') 27 | }) 28 | .catch(err => reject(err)); 29 | }); 30 | } 31 | 32 | function getDidWorkflowSid(workspaceSid) { 33 | return new Promise((resolve, reject) => { 34 | client.taskrouter.workspaces(workspaceSid) 35 | .workflows 36 | .list({limit: 20}) 37 | .then(workflows => { 38 | workflows.forEach(wf => { 39 | if (wf.friendlyName === "Direct Inward Dial") { 40 | console.log(`Found Direct Inward Dial Workflow ${wf.sid}.`) 41 | resolve(wf.sid); 42 | return; 43 | } 44 | }); 45 | 46 | reject('Could not find Direct Inward Dial workflow. You can create one by running workflow-setup'); 47 | return; 48 | }) 49 | .catch(err => reject(err)); 50 | }) 51 | } 52 | 53 | function getVoiceTaskChannelSid(workspaceSid) { 54 | return new Promise((resolve, reject) => { 55 | client.taskrouter.workspaces(workspaceSid) 56 | .taskChannels 57 | .list({limit: 20}) 58 | .then(taskChannels => { 59 | taskChannels.forEach(tc => { 60 | if (tc.friendlyName === "Voice") { 61 | console.log(`Found Voice TaskChannel Sid ${tc.sid}.`) 62 | resolve(tc.sid); 63 | return; 64 | } 65 | }); 66 | 67 | reject('Could not find Voice TaskChannel. Did you change the TaskChannel friendly name?') 68 | }) 69 | .catch(err => reject(err)); 70 | }) 71 | } 72 | 73 | function createDidStudioFlow(didWorkflowSid, voiceTaskChannelSid) { 74 | console.log( 75 | "Creating the Studio Flow." 76 | ); 77 | 78 | const studioFlowDef = { 79 | "description": "Direct Inbound", 80 | "states": [ 81 | { 82 | "name": "Trigger", 83 | "type": "trigger", 84 | "transitions": [ 85 | { 86 | "event": "incomingMessage" 87 | }, 88 | { 89 | "next": "Welcome", 90 | "event": "incomingCall" 91 | }, 92 | { 93 | "event": "incomingRequest" 94 | } 95 | ], 96 | "properties": { 97 | "offset": { 98 | "x": 40, 99 | "y": -120 100 | } 101 | } 102 | }, 103 | { 104 | "name": "ConnectToAgentByDID", 105 | "type": "send-to-flex", 106 | "transitions": [ 107 | { 108 | "event": "callComplete" 109 | }, 110 | { 111 | "event": "failedToEnqueue" 112 | }, 113 | { 114 | "event": "callFailure" 115 | } 116 | ], 117 | "properties": { 118 | "offset": { 119 | "x": 70, 120 | "y": 330 121 | }, 122 | "workflow": didWorkflowSid, 123 | "channel": voiceTaskChannelSid, 124 | "attributes": "{\n\"name\": \"{{trigger.call.From}}\" ,\n\"phone_number\": \"{{trigger.call.To}}\",\n\"type\": \"inbound\", \n\"direction\": \"inbound\"}", 125 | } 126 | }, 127 | { 128 | "name": "Welcome", 129 | "type": "say-play", 130 | "transitions": [ 131 | { 132 | "next": "ConnectToAgentByDID", 133 | "event": "audioComplete" 134 | } 135 | ], 136 | "properties": { 137 | "voice": "Polly.Joanna", 138 | "offset": { 139 | "x": 60, 140 | "y": 70 141 | }, 142 | "loop": 1, 143 | "say": " ", 144 | "language": "en-US" 145 | } 146 | } 147 | ], 148 | "initial_state": "Trigger", 149 | "flags": { 150 | "allow_concurrent_calls": true 151 | } 152 | } 153 | 154 | client.studio.flows 155 | .create({ 156 | commitMessage: 'First draft', 157 | friendlyName: 'Direct Inward Dial', 158 | status: 'published', 159 | definition: studioFlowDef 160 | }) 161 | .then(flow => console.log(`Done! Successfully created ${flow.sid}`)) 162 | .catch(err => console.log(err.details.errors)); 163 | } 164 | 165 | 166 | async function run() { 167 | console.log('\n=== Studio Setup ===\n') 168 | 169 | const workspaceSid = await getWorkspaceSid(); 170 | const didWorkflowSid = await getDidWorkflowSid(workspaceSid); 171 | const voiceTaskChannelSid = await getVoiceTaskChannelSid(workspaceSid); 172 | 173 | createDidStudioFlow(didWorkflowSid, voiceTaskChannelSid); 174 | } 175 | 176 | run(); -------------------------------------------------------------------------------- /setup-scripts/updateNumbers.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('dotenv').config(); 4 | 5 | const client = require('twilio')(process.env.ACCOUNT_SID, process.env.ACCOUNT_SECRET); 6 | 7 | // Find the SID for the Direct Inward Dial Studio flow 8 | client.studio.flows.list({limit: 20}) 9 | .then(flows => flows.forEach(f => { 10 | if(f.friendlyName === "Direct Inward Dial") { 11 | updatePhoneNumbers(f.sid); 12 | console.log(`Direct Inward Dial Flow SID: ${f.sid}`) 13 | } 14 | })); 15 | 16 | // Update all phone numbers in account with the Studio Flow webhook URL. 17 | function updatePhoneNumbers(flowSid) { 18 | console.log('\n=== Update Numbers ===\n') 19 | 20 | client.incomingPhoneNumbers 21 | .list() 22 | .then(incomingPhoneNumbers => { 23 | incomingPhoneNumbers.forEach(i => updateNumber(i.sid)); 24 | }) 25 | .catch(err => { 26 | console.log(err); 27 | }); 28 | 29 | function updateNumber(numberSid) { 30 | client.incomingPhoneNumbers(numberSid) 31 | .update({ 32 | voiceUrl: `https://webhooks.twilio.com/v1/Accounts/${process.env.ACCOUNT_SID}/Flows/${flowSid}` 33 | }) 34 | .then(d => { 35 | console.log(`Successfully updated ${d.phoneNumber}.`); 36 | }) 37 | .catch(err => { 38 | console.log(err); 39 | }); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /setup-scripts/workflowSetup.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('dotenv').config(); 4 | 5 | const client = require("twilio")( 6 | process.env.ACCOUNT_SID, 7 | process.env.ACCOUNT_SECRET 8 | ); 9 | 10 | function getWorkspaceSid() { 11 | console.log("Getting the Flex TaskRouter Workspace SID.") 12 | return new Promise((resolve, reject) => { 13 | client.taskrouter.workspaces 14 | .list({limit: 20}) 15 | .then(workspaces => { 16 | workspaces.forEach(w => { 17 | if (w.friendlyName === "Flex Task Assignment") { 18 | console.log(`Found Workspace ${w.sid}.`) 19 | resolve(w.sid); 20 | return; 21 | } 22 | }); 23 | 24 | reject('Could not find Flex Task Assignment workspace. Did you change the friendly name?') 25 | }) 26 | .catch(err => { 27 | reject(err); 28 | }); 29 | }); 30 | } 31 | 32 | function getEveryoneTaskQueueSid(workspaceSid) { 33 | console.log("Getting the 'Everyone' TaskQueue SID.") 34 | return new Promise((resolve, reject) => { 35 | client.taskrouter.workspaces(workspaceSid) 36 | .taskQueues 37 | .list({limit: 20}) 38 | .then(taskQueues => { 39 | taskQueues.forEach(t => { 40 | if (t.friendlyName === "Everyone") { 41 | console.log(`Found Everyone TaskQueue ${t.sid}.`) 42 | resolve(t.sid); 43 | } 44 | }) 45 | reject('Could not find the "Everyone" TaskQueue. Did you change the friendly name?') 46 | }) 47 | .catch(err => { 48 | reject(err); 49 | }); 50 | }); 51 | } 52 | 53 | function createDidWorkflow(workSpaceSid, everyoneTaskQueueSid) { 54 | console.log("Creating the DID workflow."); 55 | 56 | const workflowConfig = { 57 | task_routing: { 58 | filters: [ 59 | { 60 | filter_friendly_name: "Match By Worker Phone Number", 61 | expression: "1==1", 62 | targets: [ 63 | { 64 | queue: everyoneTaskQueueSid, 65 | expression: "task.phone_number CONTAINS worker.phone_number", 66 | timeout: 60, 67 | skip_if: "1==1" 68 | }, 69 | { 70 | queue: everyoneTaskQueueSid 71 | } 72 | ] 73 | } 74 | ], 75 | default_filter: { 76 | queue: everyoneTaskQueueSid 77 | } 78 | } 79 | } 80 | 81 | client.taskrouter 82 | .workspaces(workSpaceSid) 83 | .workflows 84 | .create({ 85 | friendlyName: 'Direct Inward Dial', 86 | configuration: JSON.stringify(workflowConfig) 87 | }) 88 | .then((workflow) => console.log(`Done! Successfully created the Direct Inward Dial workflow: ${workflow.sid}`)) 89 | .catch((err) => console.log(err)); 90 | } 91 | 92 | async function run() { 93 | console.log('\n=== TaskRouter Setup ===\n') 94 | 95 | let workspaceSid = await getWorkspaceSid(); 96 | let everyoneTaskQueueSid = await getEveryoneTaskQueueSid(workspaceSid); 97 | 98 | createDidWorkflow(workspaceSid, everyoneTaskQueueSid); 99 | } 100 | 101 | run(); --------------------------------------------------------------------------------