├── .env.example ├── .eslintrc.json ├── .github └── workflows │ └── test.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── PULL_REQUEST_TEMPLATE.md ├── README.md ├── index.ts ├── jest.config.js ├── license ├── package-lock.json ├── package.json ├── scripts ├── areaCodeGeos.ts └── preparePhoneNumberMap.ts ├── src ├── @types │ └── types.ts ├── app │ └── app.ts ├── config │ ├── phoneBatchSize.ts │ ├── retry.config.ts │ └── webhookValidationConfig.ts ├── controllers │ ├── conversationsPostEvent.ts │ ├── inboundCall.ts │ ├── index.ts │ └── session.ts ├── data │ ├── areaCodeProximityMap.json │ ├── phoneNumberMap.json │ └── phoneNumbers.txt ├── middlewares │ └── index.ts ├── routes │ └── index.ts ├── services │ ├── .DS_Store │ ├── geoRouter.service.ts │ ├── inboundCall.service.ts │ └── session.service.ts ├── twilioClient.ts └── utils │ ├── addParticipant.util.ts │ ├── createConversation.util.ts │ ├── deleteConversation.util.ts │ ├── generateConferenceName.util.ts │ ├── getConversationByAddressPair.util.ts │ ├── index.ts │ ├── listConversationParticipants.util.ts │ ├── listParticipantConversations.util.ts │ ├── participantsToDial.util.ts │ ├── phoneNumberParser.ts │ └── retryAddParticipant.util.ts ├── tests ├── .jest │ └── setEnvVars.js ├── controllers │ ├── conversationsPostEvent.test.ts │ ├── inboundCall.test.ts │ └── session.test.ts ├── loadtest.js ├── services │ ├── geoRouter.test.ts │ ├── inboundCall.test.ts │ └── session.test.ts ├── support │ └── testSupport.ts └── utils │ ├── addParticipant.test.ts │ ├── createConversation.test.ts │ ├── deleteConversation.test.ts │ ├── generateConferenceName.test.ts │ ├── getConversationByAddressPair.test.ts │ ├── listConversationParticipants.test.ts │ ├── listParticipantConversations.test.ts │ ├── participantsToDial.test.ts │ ├── phoneNumberParser.test.ts │ └── retryAddParticipant.test.ts ├── tsconfig.json └── yarn.lock /.env.example: -------------------------------------------------------------------------------- 1 | TWILIO_ACCOUNT_SID= 2 | TWILIO_AUTH_TOKEN= 3 | CALL_ANNOUCEMENT_VOICE= 4 | CALL_ANNOUCEMENT_LANGUAGE= 5 | OUT_OF_SESSION_MESSAGE_FOR_CALL= 6 | CONNECTING_CALL_ANNOUCEMENT= 7 | DOMAIN= 8 | AUTH_USERNAME= 9 | AUTH_PASSWORD= -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "standard" 9 | ], 10 | "parser": "@typescript-eslint/parser", 11 | "parserOptions": { 12 | "ecmaVersion": 12, 13 | "sourceType": "module" 14 | }, 15 | "plugins": [ 16 | "@typescript-eslint" 17 | ], 18 | "rules": { 19 | }, 20 | "overrides": [ 21 | { 22 | "files": [ 23 | "**/*.test.js", 24 | "**/*.test.ts" 25 | ], 26 | "env": { 27 | "jest": true 28 | } 29 | } 30 | ] 31 | } -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: [push, pull_request] 7 | 8 | jobs: 9 | build: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [16.x] 16 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: 'npm' 25 | - run: npm install 26 | - run: npm test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env.local 3 | .env 4 | launch.json 5 | coverage/ 6 | .vscode 7 | .DS_Store -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at open-source@twilio.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Twilio 2 | 3 | All third party contributors acknowledge that any contributions they provide will be made under the same open source license that the open source project is provided under. 4 | -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | **Contributing to Twilio** 4 | 5 | > All third-party contributors acknowledge that any contributions they provide will be made under the same open-source license that the open-source project is provided under. 6 | 7 | - [ ] I acknowledge that all my contributions will be made under the project's license. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Masked Communications App 2 | [![Tests](https://github.com/twilio-labs/masked-communications-app/actions/workflows/test.yml/badge.svg)](https://github.com/twilio-labs/masked-communications-app/workflows/test.yml) 3 | 4 | This is an open-source version of [Twilio Proxy](https://www.twilio.com/docs/proxy), built on top of the [Twilio Conversations API](https://www.twilio.com/docs/conversations). It adds the following features to conversations: 5 | 6 | - 🤿 Add 2-50 SMS participants to a conversation with masked numbers 7 | - 🔀 Automatic proxy number selection from a number pool 8 | - ☎️ Supports 1:1 and conference calling via the Conversations proxy number. 9 | 10 | Reasons you might use this app: 11 | - 🏠 You're a real estate company that wants to connect realtors with prospective buyers over a masked number. 12 | - 🚗 You're a rideshare service that wants to give riders a temporary number to call their driver. 13 | - 🎁 You're a personal shopper platform that wants to connect shoppers with customers over a private number and log all conversation messages. 14 | 15 | Using the underlying Twilio Conversations platform, you also have the capability to do things like: 16 | - 🚫 Block messages from going out based on their content. 17 | - 🗃 Store messages to a database for analysis. 18 | - 🏁 Receive webhooks from the Conversations event framework. 19 | 20 | # Prerequisites 21 | - A Twilio Account, you can get a free one [by signing up here](https://twilio.com/try-twilio) 22 | - 1 or [more](https://www.twilio.com/docs/proxy/phone-numbers-needed) Twilio phone numbers to mask SMS and voice communications 23 | - Node.js v14 or higher 24 | - Yarn or NPM 25 | - If you're running the app locally, you'll need a tool like [ngrok](https://ngrok.com/) so that Twilio can send webhooks to your app. 26 | 27 | # Getting Started 28 | Begin by cloning the repository, installing dependencies, and setting environment variables: 29 | 30 | ```bash 31 | # Clone the repository: 32 | $ git clone git@github.com:twilio-labs/masked-communications-app.git 33 | 34 | # Make the repository your working directory: 35 | $ cd masked-communications-app 36 | 37 | # Install dependencies: 38 | $ yarn install 39 | 40 | # Copy the example envrionment variable file: 41 | $ cp .env.example .env 42 | ``` 43 | 44 | | Variable Name | Description | Example | 45 | |---------------------------------|-------------------------------------------------------------------------------|------------------------------------------------------------| 46 | | TWILIO_ACCOUNT_SID | The identifier for your Twilio Account. | ACXXXXXXXXXXXXXXXXXXXXXXXXXXXX | 47 | | TWILIO_AUTH_TOKEN | The auth token for accessing the Twilio API. | ****************************** | 48 | | NUMBER_POOL | An array of phone numbers to use as your number pool in e164 format. | ["+141512345678", "+14230509876"] | 49 | | CALL_ANNOUCEMENT_VOICE | The type of voice to use for speech to text announcements. | "man", "woman", "alice", or any of the [Amazon Polly voices](https://www.twilio.com/docs/voice/twiml/say/text-speech#polly-standard-and-neural-voices). | 50 | | CALL_ANNOUCEMENT_LANGUAGE | The language to speak announcements in. | "en" or any of the [supported languages](https://www.twilio.com/docs/voice/twiml/say#attributes-language). | 51 | | OUT_OF_SESSION_MESSAGE_FOR_CALL | A message to play if someone calls the number pool without an active session. | "Your session is no longer active. Goodbye." | 52 | | CONNECTING_CALL_ANNOUCEMENT | A message to play when a caller is being connected to the other party. | "We're connecting you to your agent now." | 53 | | DOMAIN | The domain where the application will be hosted. | "mysite.com" or "your-domain.ngrok.io" (no https://) | 54 | | AUTH_USERNAME | Basic auth username for request authorization | "mySecureUserName" | 55 | | AUTH_PASSWORD | Basic auth password for request authorization | "mySecretPassword" | 56 | 57 | Once you have your environment variables set, you can start the app with this command: 58 | 59 | ```bash 60 | $ yarn dev 61 | 62 | # or 63 | 64 | $ npm run dev 65 | ``` 66 | 67 | To open a tunnel to your localhost that Twilio can send webhooks to, you can use ngrok: 68 | 69 | ```bash 70 | $ ngrok http 3000 71 | ``` 72 | 73 | # Configuring Webhooks 74 | Two webhooks can be configured in the Twilio Console: 75 | 76 | 1. Incoming call webhook: receives a request whenever a user makes an inbound call and connects them to the right people. 77 | - Go to Twilio Console > Phone Numbers > Manage > Active Numbers > Click on the number to configure. 78 | - Scroll down to the "Voice & Fax" configuration section to "A Call Comes In". 79 | - Select "Webhook" from the dropdown and paste in your webhook: `https://[your-domain]/inbound-call`. 80 | 81 | 2. Conversation post-event webhook: this webhook can receive "conversation closed" events from Twilio Conversations and automatically delete the closed conversation. Keeping the pool of conversations small improves app performance and reduces cost. 82 | 83 | - Go to Twilio Console > Conversations > Manage > Services > Your Service > Webhooks. 84 | - Check the `onConversationStateUpdated` box. 85 | - Paste your webhook (`https://[your-domain]/conversations-post-event`) into the Post-Event URL input box. 86 | - Click "save" at the bottom of the page. 87 | 88 | # Authentication & Webhook Validation 89 | The app requires basic auth on request to the `/sessions` endpoint. This prevents an unauthorized person from creating sessions. To use basic auth, make sure `DOMAIN` (e.g. mysite.com, no http://), `AUTH_USERNAME`, and `AUTH_PASSWORD` are all set in your .env file, and restart the app. 90 | 91 | Webhooks are automatically validated using the Twilio Webhook signature. This prevents an unauthorized request to start a phone call without your permission. For webhook validation to work, your app needs `DOMAIN` to be set along with `TWILIO_AUTH_TOKEN` in the .env file. 92 | 93 | # Usage 94 | You can create a new masked-number session between multiple users by making a post request to the `/sessions` endpoint: 95 | 96 | ```bash 97 | curl --location --request POST 'localhost:3000/sessions' \ 98 | --header 'Authorization: Basic 123XYZ==' \ 99 | --header 'Content-Type: application/json' \ 100 | --data-raw '{ 101 | "addresses": [ 102 | "+1234567890", 103 | "+0123456789" 104 | ], 105 | "friendlyName": "My First Conversation" 106 | }' 107 | ``` 108 | - The app supports basic auth, which can be configured in the .env file. 109 | - Addresses is an array of e164 formatted phone numbers. You can add between 1-50 participants to a conversation at a time. 110 | - The app also accepts all [conversations CREATE properties](https://www.twilio.com/docs/conversations/api/conversation-resource#conversation-properties), e.g. `friendlyName`. 111 | 112 | The app will respond with the JSON from a typical Create Converation API call: 113 | 114 | ```json 115 | { 116 | "accountSid": "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", 117 | "chatServiceSid": "ISXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", 118 | "messagingServiceSid": "MGXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", 119 | "sid": "CHXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", 120 | "friendlyName": "My First Conversation", 121 | "uniqueName": null, 122 | "attributes": "{}", 123 | "state": "active", 124 | "dateCreated": "2022-07-22T05:41:18.000Z", 125 | "dateUpdated": "2022-07-22T05:41:18.000Z", 126 | "timers": {}, 127 | "url": "https://conversations.twilio.com/v1/Conversations/CHXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", 128 | "links": { 129 | "participants": "https://conversations.twilio.com/v1/Conversations/CHXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Participants", 130 | "messages": "https://conversations.twilio.com/v1/Conversations/CHXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Messages", 131 | "webhooks": "https://conversations.twilio.com/v1/Conversations/CHXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Webhooks" 132 | }, 133 | "bindings": null 134 | } 135 | ``` 136 | 137 | # Errors and Retries 138 | - The app will retry requests to Twilio when it recieves a `429 - Too many requests` error code. You can configure the retry behavior in `src/config/retry.config.ts`. 139 | 140 | - If you don't have enough phone numbers in your number pool, you'll receive a 500 response with the message `Not enough numbers available in pool for [phone_number]`. 141 | 142 | # Running Tests 143 | To execute unit tests, run: 144 | 145 | ```bash 146 | $ yarn test 147 | ``` 148 | 149 | To conduct a load test on the app, run: 150 | ```bash 151 | $ yarn loadtest 152 | ``` 153 | This will generate 300 conversations in 20ms intervals against the app. 154 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Twilio Inc. 2 | 3 | import {app} from './src/app/app' 4 | 5 | const PORT = process.env.PORT || 3000 6 | 7 | /**************************************************** 8 | Start Server 9 | ****************************************************/ 10 | 11 | app.listen(PORT, () => { 12 | console.log(`Server running on port:${PORT}`) 13 | }) 14 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | setupFiles: ['/tests/.jest/setEnvVars.js'] 6 | } 7 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "async-retry": "^1.3.3", 4 | "body-parser": "^1.20.0", 5 | "cors": "^2.8.5", 6 | "dotenv": "^16.0.1", 7 | "express": "^4.18.1", 8 | "express-basic-auth": "^1.2.1", 9 | "http-errors": "^2.0.0", 10 | "moq.ts": "^9.0.2", 11 | "morgan": "^1.10.0", 12 | "twilio": "^3.78.0" 13 | }, 14 | "devDependencies": { 15 | "@types/async-retry": "^1.4.4", 16 | "@types/cors": "^2.8.12", 17 | "@types/express": "^4.17.13", 18 | "@types/http-errors": "^1.8.2", 19 | "@types/jest": "^28.1.5", 20 | "@types/morgan": "^1.9.3", 21 | "@types/node": "^18.0.1", 22 | "@types/supertest": "^2.0.12", 23 | "@typescript-eslint/eslint-plugin": "^5.32.0", 24 | "@typescript-eslint/parser": "^5.32.0", 25 | "eslint": "^8.21.0", 26 | "eslint-config-standard": "^17.0.0", 27 | "eslint-plugin-import": "^2.26.0", 28 | "eslint-plugin-n": "^15.2.4", 29 | "eslint-plugin-promise": "^6.0.0", 30 | "jest": "^28.1.3", 31 | "node-fetch": "2", 32 | "nodemon": "^2.0.19", 33 | "supertest": "^6.2.4", 34 | "ts-jest": "^28.0.6", 35 | "ts-node": "^10.8.2", 36 | "typescript": "^4.7.4" 37 | }, 38 | "name": "masked-comms-app", 39 | "scripts": { 40 | "dev": "nodemon -r dotenv/config index.ts", 41 | "loadtest": "node ./tests/loadtest.js", 42 | "prepare": "ts-node scripts/preparePhoneNumberMap.ts", 43 | "test": "jest" 44 | }, 45 | "version": "1.0.0", 46 | "main": "index.js", 47 | "repository": "git@github.com:aymenn/masked-comms-app.git", 48 | "license": "MIT" 49 | } 50 | -------------------------------------------------------------------------------- /scripts/areaCodeGeos.ts: -------------------------------------------------------------------------------- 1 | const areaCodeGeos = { 2 | ca: [ 3 | { 4 | areaCode: 204, 5 | latitude: 51.203033636364, 6 | longitude: -98.729934545455 7 | }, 8 | { 9 | areaCode: 226, 10 | latitude: 43.233831176471, 11 | longitude: -81.230922352941 12 | }, 13 | { 14 | areaCode: 236, 15 | latitude: 50.432725294118, 16 | longitude: -121.51536941176 17 | }, 18 | { 19 | areaCode: 249, 20 | latitude: 46.041822857143, 21 | longitude: -80.003286428571 22 | }, 23 | { 24 | areaCode: 250, 25 | latitude: 50.837259090909, 26 | longitude: -121.84152 27 | }, 28 | { 29 | areaCode: 289, 30 | latitude: 43.5654075, 31 | longitude: -79.333166 32 | }, 33 | { 34 | areaCode: 306, 35 | latitude: 51.399736363636, 36 | longitude: -105.68861454545 37 | }, 38 | { 39 | areaCode: 343, 40 | latitude: 44.955182142857, 41 | longitude: -76.08718 42 | }, 43 | { 44 | areaCode: 365, 45 | latitude: 43.575115, 46 | longitude: -79.3496 47 | }, 48 | { 49 | areaCode: 403, 50 | latitude: 51.2093535, 51 | longitude: -113.591395 52 | }, 53 | { 54 | areaCode: 416, 55 | latitude: 43.459085, 56 | longitude: -79.70173 57 | }, 58 | { 59 | areaCode: 418, 60 | latitude: 47.215538085106, 61 | longitude: -71.384436170213 62 | }, 63 | { 64 | areaCode: 431, 65 | latitude: 51.203033636364, 66 | longitude: -98.729934545455 67 | }, 68 | { 69 | areaCode: 437, 70 | latitude: 43.70011, 71 | longitude: -79.4163 72 | }, 73 | { 74 | areaCode: 438, 75 | latitude: 45.5546, 76 | longitude: -73.88272 77 | }, 78 | { 79 | areaCode: 450, 80 | latitude: 45.789232765957, 81 | longitude: -73.216014255319 82 | }, 83 | { 84 | areaCode: 506, 85 | latitude: 46.566092222222, 86 | longitude: -66.060475555556 87 | }, 88 | { 89 | areaCode: 514, 90 | latitude: 46.126976, 91 | longitude: -73.355944 92 | }, 93 | { 94 | areaCode: 519, 95 | latitude: 43.2104985, 96 | longitude: -81.1431145 97 | }, 98 | { 99 | areaCode: 579, 100 | latitude: 45.756958648649, 101 | longitude: -73.27781027027 102 | }, 103 | { 104 | areaCode: 581, 105 | latitude: 47.26140744186, 106 | longitude: -71.35332372093 107 | }, 108 | { 109 | areaCode: 587, 110 | latitude: 52.688353636364, 111 | longitude: -113.70208954545 112 | }, 113 | { 114 | areaCode: 604, 115 | latitude: 49.30576625, 116 | longitude: -122.98475625 117 | }, 118 | { 119 | areaCode: 613, 120 | latitude: 44.936384705882, 121 | longitude: -76.402184705882 122 | }, 123 | { 124 | areaCode: 639, 125 | latitude: 51.636756, 126 | longitude: -106.001166 127 | }, 128 | { 129 | areaCode: 647, 130 | latitude: 43.70011, 131 | longitude: -79.4163 132 | }, 133 | { 134 | areaCode: 705, 135 | latitude: 45.7227065, 136 | longitude: -80.313261 137 | }, 138 | { 139 | areaCode: 709, 140 | latitude: 48.949732857143, 141 | longitude: -55.959877142857 142 | }, 143 | { 144 | areaCode: 778, 145 | latitude: 50.337179795918, 146 | longitude: -122.21482163265 147 | }, 148 | { 149 | areaCode: 780, 150 | latitude: 53.9300256, 151 | longitude: -113.8699808 152 | }, 153 | { 154 | areaCode: 782, 155 | latitude: 45.0743675, 156 | longitude: -63.865825 157 | }, 158 | { 159 | areaCode: 807, 160 | latitude: 47.495825, 161 | longitude: -88.84699 162 | }, 163 | { 164 | areaCode: 819, 165 | latitude: 46.339735, 166 | longitude: -73.465640277778 167 | }, 168 | { 169 | areaCode: 825, 170 | latitude: 53.548798, 171 | longitude: -113.321158 172 | }, 173 | { 174 | areaCode: 867, 175 | latitude: 62.30636, 176 | longitude: -105.97452333333 177 | }, 178 | { 179 | areaCode: 873, 180 | latitude: 46.469091363636, 181 | longitude: -74.104794090909 182 | }, 183 | { 184 | areaCode: 902, 185 | latitude: 45.441521666667, 186 | longitude: -63.155714166667 187 | }, 188 | { 189 | areaCode: 905, 190 | latitude: 43.5654075, 191 | longitude: -79.333166 192 | } 193 | ], 194 | us: [ 195 | { 196 | areaCode: 201, 197 | latitude: 40.83885, 198 | longitude: -74.045678125 199 | }, 200 | { 201 | areaCode: 202, 202 | latitude: 38.89511, 203 | longitude: -77.03637 204 | }, 205 | { 206 | areaCode: 203, 207 | latitude: 41.291798125, 208 | longitude: -73.122453125 209 | }, 210 | { 211 | areaCode: 205, 212 | latitude: 33.427671111111, 213 | longitude: -86.886473333333 214 | }, 215 | { 216 | areaCode: 206, 217 | latitude: 47.564027142857, 218 | longitude: -122.34897571429 219 | }, 220 | { 221 | areaCode: 207, 222 | latitude: 44.000005, 223 | longitude: -69.987965 224 | }, 225 | { 226 | areaCode: 208, 227 | latitude: 44.415674, 228 | longitude: -115.558462 229 | }, 230 | { 231 | areaCode: 209, 232 | latitude: 37.604049, 233 | longitude: -120.999087 234 | }, 235 | { 236 | areaCode: 210, 237 | latitude: 29.42412, 238 | longitude: -98.49363 239 | }, 240 | { 241 | areaCode: 212, 242 | latitude: 40.71427, 243 | longitude: -74.00597 244 | }, 245 | { 246 | areaCode: 213, 247 | latitude: 34.05223, 248 | longitude: -118.24368 249 | }, 250 | { 251 | areaCode: 214, 252 | latitude: 32.863500833333, 253 | longitude: -96.837767916667 254 | }, 255 | { 256 | areaCode: 215, 257 | latitude: 40.05372, 258 | longitude: -74.99628 259 | }, 260 | { 261 | areaCode: 216, 262 | latitude: 41.4749, 263 | longitude: -81.616895454545 264 | }, 265 | { 266 | areaCode: 217, 267 | latitude: 39.917895714286, 268 | longitude: -88.895028571429 269 | }, 270 | { 271 | areaCode: 218, 272 | latitude: 46.29681, 273 | longitude: -94.05581 274 | }, 275 | { 276 | areaCode: 219, 277 | latitude: 41.566263333333, 278 | longitude: -87.2636 279 | }, 280 | { 281 | areaCode: 220, 282 | latitude: 39.74919875, 283 | longitude: -82.66145 284 | }, 285 | { 286 | areaCode: 224, 287 | latitude: 42.14730875, 288 | longitude: -87.947605625 289 | }, 290 | { 291 | areaCode: 225, 292 | latitude: 30.45075, 293 | longitude: -91.15455 294 | }, 295 | { 296 | areaCode: 228, 297 | latitude: 30.388626666667, 298 | longitude: -88.845213333333 299 | }, 300 | { 301 | areaCode: 229, 302 | latitude: 31.205925, 303 | longitude: -83.71803 304 | }, 305 | { 306 | areaCode: 231, 307 | latitude: 43.02465, 308 | longitude: -85.181773333333 309 | }, 310 | { 311 | areaCode: 234, 312 | latitude: 41.063504444444, 313 | longitude: -81.314626111111 314 | }, 315 | { 316 | areaCode: 239, 317 | latitude: 26.456114285714, 318 | longitude: -81.800608571429 319 | }, 320 | { 321 | areaCode: 240, 322 | latitude: 39.047505, 323 | longitude: -77.121279285714 324 | }, 325 | { 326 | areaCode: 248, 327 | latitude: 42.543724615385, 328 | longitude: -83.262428461538 329 | }, 330 | { 331 | areaCode: 251, 332 | latitude: 30.71658, 333 | longitude: -88.06097 334 | }, 335 | { 336 | areaCode: 252, 337 | latitude: 35.42039, 338 | longitude: -77.43325 339 | }, 340 | { 341 | areaCode: 253, 342 | latitude: 47.228641, 343 | longitude: -122.365951 344 | }, 345 | { 346 | areaCode: 254, 347 | latitude: 31.204726, 348 | longitude: -97.579188 349 | }, 350 | { 351 | areaCode: 256, 352 | latitude: 34.418243333333, 353 | longitude: -86.638881666667 354 | }, 355 | { 356 | areaCode: 260, 357 | latitude: 41.1306, 358 | longitude: -85.12886 359 | }, 360 | { 361 | areaCode: 262, 362 | latitude: 42.991375, 363 | longitude: -88.039892 364 | }, 365 | { 366 | areaCode: 267, 367 | latitude: 40.05372, 368 | longitude: -74.99628 369 | }, 370 | { 371 | areaCode: 269, 372 | latitude: 42.270053333333, 373 | longitude: -85.448463333333 374 | }, 375 | { 376 | areaCode: 270, 377 | latitude: 37.44057, 378 | longitude: -87.006631428571 379 | }, 380 | { 381 | areaCode: 272, 382 | latitude: 41.238118, 383 | longitude: -76.10365 384 | }, 385 | { 386 | areaCode: 276, 387 | latitude: 37.55376, 388 | longitude: -77.46026 389 | }, 390 | { 391 | areaCode: 281, 392 | latitude: 29.709178888889, 393 | longitude: -95.300398888889 394 | }, 395 | { 396 | areaCode: 301, 397 | latitude: 39.047505, 398 | longitude: -77.121279285714 399 | }, 400 | { 401 | areaCode: 302, 402 | latitude: 39.52928, 403 | longitude: -75.606873333333 404 | }, 405 | { 406 | areaCode: 303, 407 | latitude: 39.77755625, 408 | longitude: -105.00266666667 409 | }, 410 | { 411 | areaCode: 304, 412 | latitude: 39.361145, 413 | longitude: -81.162093333333 414 | }, 415 | { 416 | areaCode: 305, 417 | latitude: 25.740417037037, 418 | longitude: -80.35898037037 419 | }, 420 | { 421 | areaCode: 307, 422 | latitude: 42.4022675, 423 | longitude: -105.5566625 424 | }, 425 | { 426 | areaCode: 308, 427 | latitude: 40.91612, 428 | longitude: -99.396303333333 429 | }, 430 | { 431 | areaCode: 309, 432 | latitude: 40.93229, 433 | longitude: -89.855943333333 434 | }, 435 | { 436 | areaCode: 310, 437 | latitude: 33.899128125, 438 | longitude: -118.352904375 439 | }, 440 | { 441 | areaCode: 312, 442 | latitude: 41.88475, 443 | longitude: -88.20396 444 | }, 445 | { 446 | areaCode: 313, 447 | latitude: 42.312228888889, 448 | longitude: -83.201191111111 449 | }, 450 | { 451 | areaCode: 314, 452 | latitude: 38.649496666667, 453 | longitude: -90.323053333333 454 | }, 455 | { 456 | areaCode: 315, 457 | latitude: 43.253676, 458 | longitude: -75.862524 459 | }, 460 | { 461 | areaCode: 316, 462 | latitude: 37.69224, 463 | longitude: -97.33754 464 | }, 465 | { 466 | areaCode: 317, 467 | latitude: 39.866706666667, 468 | longitude: -86.071743333333 469 | }, 470 | { 471 | areaCode: 318, 472 | latitude: 32.278824, 473 | longitude: -92.942602 474 | }, 475 | { 476 | areaCode: 319, 477 | latitude: 41.921948333333, 478 | longitude: -91.778878333333 479 | }, 480 | { 481 | areaCode: 320, 482 | latitude: 45.5608, 483 | longitude: -94.16249 484 | }, 485 | { 486 | areaCode: 321, 487 | latitude: 28.3756865, 488 | longitude: -81.1535335 489 | }, 490 | { 491 | areaCode: 323, 492 | latitude: 33.987178181818, 493 | longitude: -118.21200545455 494 | }, 495 | { 496 | areaCode: 325, 497 | latitude: 31.956255, 498 | longitude: -100.08509 499 | }, 500 | { 501 | areaCode: 330, 502 | latitude: 41.063504444444, 503 | longitude: -81.314626111111 504 | }, 505 | { 506 | areaCode: 331, 507 | latitude: 41.8771732, 508 | longitude: -88.0948236 509 | }, 510 | { 511 | areaCode: 334, 512 | latitude: 32.187855, 513 | longitude: -85.86078875 514 | }, 515 | { 516 | areaCode: 336, 517 | latitude: 35.986358, 518 | longitude: -79.858592 519 | }, 520 | { 521 | areaCode: 337, 522 | latitude: 30.242168, 523 | longitude: -92.500372 524 | }, 525 | { 526 | areaCode: 339, 527 | latitude: 42.389215652174, 528 | longitude: -71.100365652174 529 | }, 530 | { 531 | areaCode: 346, 532 | latitude: 29.709178888889, 533 | longitude: -95.300398888889 534 | }, 535 | { 536 | areaCode: 347, 537 | latitude: 40.691608, 538 | longitude: -73.959668 539 | }, 540 | { 541 | areaCode: 351, 542 | latitude: 42.603682857143, 543 | longitude: -71.226208571429 544 | }, 545 | { 546 | areaCode: 352, 547 | latitude: 28.372005, 548 | longitude: -81.7805925 549 | }, 550 | { 551 | areaCode: 360, 552 | latitude: 47.411182222222, 553 | longitude: -122.57029111111 554 | }, 555 | { 556 | areaCode: 361, 557 | latitude: 28.040573333333, 558 | longitude: -97.418696666667 559 | }, 560 | { 561 | areaCode: 364, 562 | latitude: 37.44057, 563 | longitude: -87.006631428571 564 | }, 565 | { 566 | areaCode: 385, 567 | latitude: 40.653856428571, 568 | longitude: -111.88019214286 569 | }, 570 | { 571 | areaCode: 386, 572 | latitude: 29.167798571429, 573 | longitude: -81.110851428571 574 | }, 575 | { 576 | areaCode: 401, 577 | latitude: 41.773583, 578 | longitude: -71.418123 579 | }, 580 | { 581 | areaCode: 402, 582 | latitude: 41.238971428571, 583 | longitude: -96.881062857143 584 | }, 585 | { 586 | areaCode: 404, 587 | latitude: 33.734668888889, 588 | longitude: -84.372366666667 589 | }, 590 | { 591 | areaCode: 405, 592 | latitude: 35.504228, 593 | longitude: -97.413675 594 | }, 595 | { 596 | areaCode: 406, 597 | latitude: 46.40531, 598 | longitude: -111.567485 599 | }, 600 | { 601 | areaCode: 407, 602 | latitude: 28.393690666667, 603 | longitude: -81.308249333333 604 | }, 605 | { 606 | areaCode: 408, 607 | latitude: 37.267165555556, 608 | longitude: -121.88483222222 609 | }, 610 | { 611 | areaCode: 409, 612 | latitude: 29.66411, 613 | longitude: -94.436145 614 | }, 615 | { 616 | areaCode: 410, 617 | latitude: 39.249978709677, 618 | longitude: -76.587990645161 619 | }, 620 | { 621 | areaCode: 412, 622 | latitude: 40.399266666667, 623 | longitude: -79.919527777778 624 | }, 625 | { 626 | areaCode: 413, 627 | latitude: 42.19644625, 628 | longitude: -72.713425 629 | }, 630 | { 631 | areaCode: 414, 632 | latitude: 42.946165714286, 633 | longitude: -87.950007142857 634 | }, 635 | { 636 | areaCode: 415, 637 | latitude: 37.927565, 638 | longitude: -122.517545 639 | }, 640 | { 641 | areaCode: 417, 642 | latitude: 37.14978, 643 | longitude: -93.90576 644 | }, 645 | { 646 | areaCode: 419, 647 | latitude: 41.128782857143, 648 | longitude: -83.21476 649 | }, 650 | { 651 | areaCode: 423, 652 | latitude: 35.841677142857, 653 | longitude: -83.68173 654 | }, 655 | { 656 | areaCode: 424, 657 | latitude: 33.899128125, 658 | longitude: -118.352904375 659 | }, 660 | { 661 | areaCode: 425, 662 | latitude: 47.733239285714, 663 | longitude: -121.86407785714 664 | }, 665 | { 666 | areaCode: 432, 667 | latitude: 32.030033333333, 668 | longitude: -102.01847 669 | }, 670 | { 671 | areaCode: 434, 672 | latitude: 37.339493333333, 673 | longitude: -79.016593333333 674 | }, 675 | { 676 | areaCode: 435, 677 | latitude: 39.270445, 678 | longitude: -112.68724 679 | }, 680 | { 681 | areaCode: 440, 682 | latitude: 41.479659333333, 683 | longitude: -81.697331333333 684 | }, 685 | { 686 | areaCode: 442, 687 | latitude: 33.669568095238, 688 | longitude: -116.75811238095 689 | }, 690 | { 691 | areaCode: 443, 692 | latitude: 39.249978709677, 693 | longitude: -76.587990645161 694 | }, 695 | { 696 | areaCode: 458, 697 | latitude: 43.77279625, 698 | longitude: -123.19441625 699 | }, 700 | { 701 | areaCode: 469, 702 | latitude: 32.869057826087, 703 | longitude: -96.826015652174 704 | }, 705 | { 706 | areaCode: 478, 707 | latitude: 32.730835, 708 | longitude: -83.61615 709 | }, 710 | { 711 | areaCode: 479, 712 | latitude: 35.849136, 713 | longitude: -93.987424 714 | }, 715 | { 716 | areaCode: 480, 717 | latitude: 33.431751428571, 718 | longitude: -111.79106428571 719 | }, 720 | { 721 | areaCode: 484, 722 | latitude: 40.251023, 723 | longitude: -75.457804 724 | }, 725 | { 726 | areaCode: 501, 727 | latitude: 34.768187142857, 728 | longitude: -92.421822857143 729 | }, 730 | { 731 | areaCode: 502, 732 | latitude: 38.227575, 733 | longitude: -85.316345 734 | }, 735 | { 736 | areaCode: 503, 737 | latitude: 45.347859333333, 738 | longitude: -122.79654266667 739 | }, 740 | { 741 | areaCode: 504, 742 | latitude: 29.941272857143, 743 | longitude: -90.092177142857 744 | }, 745 | { 746 | areaCode: 505, 747 | latitude: 35.545173333333, 748 | longitude: -107.14879333333 749 | }, 750 | { 751 | areaCode: 507, 752 | latitude: 44.048388333333, 753 | longitude: -92.93543 754 | }, 755 | { 756 | areaCode: 508, 757 | latitude: 42.006992727273, 758 | longitude: -71.224194545455 759 | }, 760 | { 761 | areaCode: 509, 762 | latitude: 46.765124444444, 763 | longitude: -118.73412111111 764 | }, 765 | { 766 | areaCode: 510, 767 | latitude: 37.43248, 768 | longitude: -121.80745230769 769 | }, 770 | { 771 | areaCode: 512, 772 | latitude: 30.359314, 773 | longitude: -97.772174 774 | }, 775 | { 776 | areaCode: 513, 777 | latitude: 39.349302857143, 778 | longitude: -84.498834285714 779 | }, 780 | { 781 | areaCode: 515, 782 | latitude: 41.840495, 783 | longitude: -93.75476 784 | }, 785 | { 786 | areaCode: 516, 787 | latitude: 40.701854736842, 788 | longitude: -73.598140526316 789 | }, 790 | { 791 | areaCode: 517, 792 | latitude: 42.487028333333, 793 | longitude: -84.137551666667 794 | }, 795 | { 796 | areaCode: 518, 797 | latitude: 42.819186, 798 | longitude: -73.833068 799 | }, 800 | { 801 | areaCode: 520, 802 | latitude: 32.118015714286, 803 | longitude: -110.97648428571 804 | }, 805 | { 806 | areaCode: 530, 807 | latitude: 39.338822857143, 808 | longitude: -121.56659428571 809 | }, 810 | { 811 | areaCode: 531, 812 | latitude: 41.238971428571, 813 | longitude: -96.881062857143 814 | }, 815 | { 816 | areaCode: 539, 817 | latitude: 36.1754425, 818 | longitude: -95.783525 819 | }, 820 | { 821 | areaCode: 540, 822 | latitude: 37.829541428571, 823 | longitude: -79.504057142857 824 | }, 825 | { 826 | areaCode: 541, 827 | latitude: 43.77279625, 828 | longitude: -123.19441625 829 | }, 830 | { 831 | areaCode: 551, 832 | latitude: 40.83885, 833 | longitude: -74.045678125 834 | }, 835 | { 836 | areaCode: 559, 837 | latitude: 36.50766, 838 | longitude: -119.5360325 839 | }, 840 | { 841 | areaCode: 561, 842 | latitude: 26.555793333333, 843 | longitude: -80.135440833333 844 | }, 845 | { 846 | areaCode: 562, 847 | latitude: 33.897952857143, 848 | longitude: -118.08427714286 849 | }, 850 | { 851 | areaCode: 563, 852 | latitude: 41.763524, 853 | longitude: -90.59797 854 | }, 855 | { 856 | areaCode: 567, 857 | latitude: 41.128782857143, 858 | longitude: -83.21476 859 | }, 860 | { 861 | areaCode: 570, 862 | latitude: 41.238118, 863 | longitude: -76.10365 864 | }, 865 | { 866 | areaCode: 571, 867 | latitude: 38.8324725, 868 | longitude: -77.270965 869 | }, 870 | { 871 | areaCode: 573, 872 | latitude: 38.278096666667, 873 | longitude: -91.341913333333 874 | }, 875 | { 876 | areaCode: 574, 877 | latitude: 41.672602, 878 | longitude: -86.066116 879 | }, 880 | { 881 | areaCode: 575, 882 | latitude: 33.022383333333, 883 | longitude: -104.63893833333 884 | }, 885 | { 886 | areaCode: 580, 887 | latitude: 35.170991666667, 888 | longitude: -97.964955 889 | }, 890 | { 891 | areaCode: 585, 892 | latitude: 43.171913333333, 893 | longitude: -77.581943333333 894 | }, 895 | { 896 | areaCode: 586, 897 | latitude: 42.539758571429, 898 | longitude: -82.970317142857 899 | }, 900 | { 901 | areaCode: 601, 902 | latitude: 32.197054285714, 903 | longitude: -89.961072857143 904 | }, 905 | { 906 | areaCode: 602, 907 | latitude: 33.44838, 908 | longitude: -112.07404 909 | }, 910 | { 911 | areaCode: 603, 912 | latitude: 43.04893875, 913 | longitude: -71.33472 914 | }, 915 | { 916 | areaCode: 605, 917 | latitude: 44.472588, 918 | longitude: -99.176772 919 | }, 920 | { 921 | areaCode: 606, 922 | latitude: 38.47841, 923 | longitude: -82.63794 924 | }, 925 | { 926 | areaCode: 607, 927 | latitude: 42.209706666667, 928 | longitude: -76.407436666667 929 | }, 930 | { 931 | areaCode: 608, 932 | latitude: 43.042495, 933 | longitude: -89.563915 934 | }, 935 | { 936 | areaCode: 609, 937 | latitude: 39.9697625, 938 | longitude: -74.7087 939 | }, 940 | { 941 | areaCode: 610, 942 | latitude: 40.251023, 943 | longitude: -75.457804 944 | }, 945 | { 946 | areaCode: 612, 947 | latitude: 44.9708, 948 | longitude: -93.32084 949 | }, 950 | { 951 | areaCode: 614, 952 | latitude: 40.008745, 953 | longitude: -83.0058775 954 | }, 955 | { 956 | areaCode: 615, 957 | latitude: 36.10672375, 958 | longitude: -86.5878425 959 | }, 960 | { 961 | areaCode: 616, 962 | latitude: 42.915765, 963 | longitude: -85.73082 964 | }, 965 | { 966 | areaCode: 617, 967 | latitude: 42.348581818182, 968 | longitude: -71.100127272727 969 | }, 970 | { 971 | areaCode: 618, 972 | latitude: 38.56595375, 973 | longitude: -89.9325675 974 | }, 975 | { 976 | areaCode: 619, 977 | latitude: 32.720583333333, 978 | longitude: -117.04660416667 979 | }, 980 | { 981 | areaCode: 620, 982 | latitude: 38.0473075, 983 | longitude: -98.7502925 984 | }, 985 | { 986 | areaCode: 623, 987 | latitude: 33.5849, 988 | longitude: -112.29812166667 989 | }, 990 | { 991 | areaCode: 626, 992 | latitude: 34.0783752, 993 | longitude: -118.0051808 994 | }, 995 | { 996 | areaCode: 628, 997 | latitude: 37.927565, 998 | longitude: -122.517545 999 | }, 1000 | { 1001 | areaCode: 629, 1002 | latitude: 36.10672375, 1003 | longitude: -86.5878425 1004 | }, 1005 | { 1006 | areaCode: 630, 1007 | latitude: 41.8771732, 1008 | longitude: -88.0948236 1009 | }, 1010 | { 1011 | areaCode: 631, 1012 | latitude: 40.79404826087, 1013 | longitude: -73.20643 1014 | }, 1015 | { 1016 | areaCode: 636, 1017 | latitude: 38.705978333333, 1018 | longitude: -90.598965 1019 | }, 1020 | { 1021 | areaCode: 641, 1022 | latitude: 42.074336666667, 1023 | longitude: -92.840106666667 1024 | }, 1025 | { 1026 | areaCode: 646, 1027 | latitude: 40.71427, 1028 | longitude: -74.00597 1029 | }, 1030 | { 1031 | areaCode: 650, 1032 | latitude: 37.53483375, 1033 | longitude: -122.284655625 1034 | }, 1035 | { 1036 | areaCode: 651, 1037 | latitude: 44.912425714286, 1038 | longitude: -93.084087857143 1039 | }, 1040 | { 1041 | areaCode: 657, 1042 | latitude: 33.804700666667, 1043 | longitude: -117.92452266667 1044 | }, 1045 | { 1046 | areaCode: 660, 1047 | latitude: 38.70446, 1048 | longitude: -93.22826 1049 | }, 1050 | { 1051 | areaCode: 661, 1052 | latitude: 35.117865714286, 1053 | longitude: -118.77457857143 1054 | }, 1055 | { 1056 | areaCode: 662, 1057 | latitude: 34.109314285714, 1058 | longitude: -89.632332857143 1059 | }, 1060 | { 1061 | areaCode: 667, 1062 | latitude: 39.249978709677, 1063 | longitude: -76.587990645161 1064 | }, 1065 | { 1066 | areaCode: 669, 1067 | latitude: 37.267165555556, 1068 | longitude: -121.88483222222 1069 | }, 1070 | { 1071 | areaCode: 678, 1072 | latitude: 33.814474782609, 1073 | longitude: -84.360855217391 1074 | }, 1075 | { 1076 | areaCode: 681, 1077 | latitude: 39.361145, 1078 | longitude: -81.162093333333 1079 | }, 1080 | { 1081 | areaCode: 682, 1082 | latitude: 32.759575333333, 1083 | longitude: -97.223403333333 1084 | }, 1085 | { 1086 | areaCode: 701, 1087 | latitude: 47.4602675, 1088 | longitude: -99.003305 1089 | }, 1090 | { 1091 | areaCode: 702, 1092 | latitude: 36.140527142857, 1093 | longitude: -115.11436428571 1094 | }, 1095 | { 1096 | areaCode: 703, 1097 | latitude: 38.8324725, 1098 | longitude: -77.270965 1099 | }, 1100 | { 1101 | areaCode: 704, 1102 | latitude: 35.367685555556, 1103 | longitude: -80.747737777778 1104 | }, 1105 | { 1106 | areaCode: 706, 1107 | latitude: 33.639484285714, 1108 | longitude: -83.94034 1109 | }, 1110 | { 1111 | areaCode: 707, 1112 | latitude: 38.514229090909, 1113 | longitude: -122.52735727273 1114 | }, 1115 | { 1116 | areaCode: 708, 1117 | latitude: 41.705734761905, 1118 | longitude: -87.72693047619 1119 | }, 1120 | { 1121 | areaCode: 712, 1122 | latitude: 41.880965, 1123 | longitude: -96.13057 1124 | }, 1125 | { 1126 | areaCode: 713, 1127 | latitude: 29.709178888889, 1128 | longitude: -95.300398888889 1129 | }, 1130 | { 1131 | areaCode: 714, 1132 | latitude: 33.804700666667, 1133 | longitude: -117.92452266667 1134 | }, 1135 | { 1136 | areaCode: 715, 1137 | latitude: 45.25371, 1138 | longitude: -90.7018125 1139 | }, 1140 | { 1141 | areaCode: 716, 1142 | latitude: 42.87564, 1143 | longitude: -78.89547375 1144 | }, 1145 | { 1146 | areaCode: 717, 1147 | latitude: 40.1537775, 1148 | longitude: -76.582255 1149 | }, 1150 | { 1151 | areaCode: 718, 1152 | latitude: 40.691608, 1153 | longitude: -73.959668 1154 | }, 1155 | { 1156 | areaCode: 719, 1157 | latitude: 38.64372, 1158 | longitude: -104.75283333333 1159 | }, 1160 | { 1161 | areaCode: 720, 1162 | latitude: 39.77755625, 1163 | longitude: -105.00266666667 1164 | }, 1165 | { 1166 | areaCode: 724, 1167 | latitude: 40.565275, 1168 | longitude: -79.9919975 1169 | }, 1170 | { 1171 | areaCode: 725, 1172 | latitude: 36.140527142857, 1173 | longitude: -115.11436428571 1174 | }, 1175 | { 1176 | areaCode: 727, 1177 | latitude: 28.006899, 1178 | longitude: -82.736329 1179 | }, 1180 | { 1181 | areaCode: 731, 1182 | latitude: 35.61452, 1183 | longitude: -88.81395 1184 | }, 1185 | { 1186 | areaCode: 732, 1187 | latitude: 40.420721428571, 1188 | longitude: -74.323679285714 1189 | }, 1190 | { 1191 | areaCode: 734, 1192 | latitude: 42.251202727273, 1193 | longitude: -83.413476363636 1194 | }, 1195 | { 1196 | areaCode: 737, 1197 | latitude: 30.359314, 1198 | longitude: -97.772174 1199 | }, 1200 | { 1201 | areaCode: 740, 1202 | latitude: 39.74919875, 1203 | longitude: -82.66145 1204 | }, 1205 | { 1206 | areaCode: 743, 1207 | latitude: 35.986358, 1208 | longitude: -79.858592 1209 | }, 1210 | { 1211 | areaCode: 747, 1212 | latitude: 34.186115, 1213 | longitude: -118.43554333333 1214 | }, 1215 | { 1216 | areaCode: 754, 1217 | latitude: 26.143138421053, 1218 | longitude: -80.201925263158 1219 | }, 1220 | { 1221 | areaCode: 757, 1222 | latitude: 36.870171428571, 1223 | longitude: -76.313514285714 1224 | }, 1225 | { 1226 | areaCode: 760, 1227 | latitude: 33.669568095238, 1228 | longitude: -116.75811238095 1229 | }, 1230 | { 1231 | areaCode: 762, 1232 | latitude: 33.639484285714, 1233 | longitude: -83.94034 1234 | }, 1235 | { 1236 | areaCode: 763, 1237 | latitude: 45.098514444444, 1238 | longitude: -93.359148888889 1239 | }, 1240 | { 1241 | areaCode: 765, 1242 | latitude: 40.289168571429, 1243 | longitude: -85.937961428571 1244 | }, 1245 | { 1246 | areaCode: 769, 1247 | latitude: 32.197054285714, 1248 | longitude: -89.961072857143 1249 | }, 1250 | { 1251 | areaCode: 770, 1252 | latitude: 33.8336605, 1253 | longitude: -84.3569235 1254 | }, 1255 | { 1256 | areaCode: 772, 1257 | latitude: 27.37194, 1258 | longitude: -80.3461 1259 | }, 1260 | { 1261 | areaCode: 773, 1262 | latitude: 41.88475, 1263 | longitude: -88.20396 1264 | }, 1265 | { 1266 | areaCode: 774, 1267 | latitude: 42.006992727273, 1268 | longitude: -71.224194545455 1269 | }, 1270 | { 1271 | areaCode: 775, 1272 | latitude: 38.6091575, 1273 | longitude: -118.82945 1274 | }, 1275 | { 1276 | areaCode: 779, 1277 | latitude: 42.050869166667, 1278 | longitude: -88.6227275 1279 | }, 1280 | { 1281 | areaCode: 781, 1282 | latitude: 42.389215652174, 1283 | longitude: -71.100365652174 1284 | }, 1285 | { 1286 | areaCode: 785, 1287 | latitude: 38.984614, 1288 | longitude: -96.88463 1289 | }, 1290 | { 1291 | areaCode: 786, 1292 | latitude: 25.740417037037, 1293 | longitude: -80.35898037037 1294 | }, 1295 | { 1296 | areaCode: 801, 1297 | latitude: 40.653856428571, 1298 | longitude: -111.88019214286 1299 | }, 1300 | { 1301 | areaCode: 802, 1302 | latitude: 44.363525, 1303 | longitude: -72.873175 1304 | }, 1305 | { 1306 | areaCode: 803, 1307 | latitude: 34.08893, 1308 | longitude: -81.057868 1309 | }, 1310 | { 1311 | areaCode: 804, 1312 | latitude: 37.456984, 1313 | longitude: -77.415816 1314 | }, 1315 | { 1316 | areaCode: 805, 1317 | latitude: 34.60204125, 1318 | longitude: -119.696259375 1319 | }, 1320 | { 1321 | areaCode: 806, 1322 | latitude: 34.328216666667, 1323 | longitude: -101.79777 1324 | }, 1325 | { 1326 | areaCode: 808, 1327 | latitude: 20.956035555556, 1328 | longitude: -157.23792666667 1329 | }, 1330 | { 1331 | areaCode: 810, 1332 | latitude: 42.994286666667, 1333 | longitude: -83.242903333333 1334 | }, 1335 | { 1336 | areaCode: 812, 1337 | latitude: 38.66688, 1338 | longitude: -86.391257142857 1339 | }, 1340 | { 1341 | areaCode: 813, 1342 | latitude: 28.047438888889, 1343 | longitude: -82.427858888889 1344 | }, 1345 | { 1346 | areaCode: 814, 1347 | latitude: 40.9420075, 1348 | longitude: -78.8154425 1349 | }, 1350 | { 1351 | areaCode: 815, 1352 | latitude: 42.050869166667, 1353 | longitude: -88.6227275 1354 | }, 1355 | { 1356 | areaCode: 816, 1357 | latitude: 39.104366, 1358 | longitude: -94.500676 1359 | }, 1360 | { 1361 | areaCode: 817, 1362 | latitude: 32.759575333333, 1363 | longitude: -97.223403333333 1364 | }, 1365 | { 1366 | areaCode: 818, 1367 | latitude: 34.186115, 1368 | longitude: -118.43554333333 1369 | }, 1370 | { 1371 | areaCode: 828, 1372 | latitude: 35.66707, 1373 | longitude: -81.94761 1374 | }, 1375 | { 1376 | areaCode: 830, 1377 | latitude: 29.478228, 1378 | longitude: -99.325156 1379 | }, 1380 | { 1381 | areaCode: 831, 1382 | latitude: 36.758607142857, 1383 | longitude: -121.77046571429 1384 | }, 1385 | { 1386 | areaCode: 832, 1387 | latitude: 29.709178888889, 1388 | longitude: -95.300398888889 1389 | }, 1390 | { 1391 | areaCode: 843, 1392 | latitude: 33.0913225, 1393 | longitude: -79.901955 1394 | }, 1395 | { 1396 | areaCode: 845, 1397 | latitude: 41.47292, 1398 | longitude: -74.064123333333 1399 | }, 1400 | { 1401 | areaCode: 847, 1402 | latitude: 42.14730875, 1403 | longitude: -87.947605625 1404 | }, 1405 | { 1406 | areaCode: 848, 1407 | latitude: 40.420721428571, 1408 | longitude: -74.323679285714 1409 | }, 1410 | { 1411 | areaCode: 850, 1412 | latitude: 30.418668888889, 1413 | longitude: -86.61346 1414 | }, 1415 | { 1416 | areaCode: 856, 1417 | latitude: 39.737218571429, 1418 | longitude: -75.053785714286 1419 | }, 1420 | { 1421 | areaCode: 857, 1422 | latitude: 42.348581818182, 1423 | longitude: -71.100127272727 1424 | }, 1425 | { 1426 | areaCode: 858, 1427 | latitude: 32.96282, 1428 | longitude: -117.03586 1429 | }, 1430 | { 1431 | areaCode: 859, 1432 | latitude: 38.4547925, 1433 | longitude: -84.4768825 1434 | }, 1435 | { 1436 | areaCode: 860, 1437 | latitude: 41.674009166667, 1438 | longitude: -72.641041666667 1439 | }, 1440 | { 1441 | areaCode: 862, 1442 | latitude: 40.825112857143, 1443 | longitude: -74.216403333333 1444 | }, 1445 | { 1446 | areaCode: 863, 1447 | latitude: 28.030855, 1448 | longitude: -81.84133 1449 | }, 1450 | { 1451 | areaCode: 864, 1452 | latitude: 34.720858333333, 1453 | longitude: -82.294563333333 1454 | }, 1455 | { 1456 | areaCode: 865, 1457 | latitude: 35.9344575, 1458 | longitude: -84.10762 1459 | }, 1460 | { 1461 | areaCode: 870, 1462 | latitude: 34.654171666667, 1463 | longitude: -91.682218333333 1464 | }, 1465 | { 1466 | areaCode: 878, 1467 | latitude: 40.459707, 1468 | longitude: -79.962276 1469 | }, 1470 | { 1471 | areaCode: 901, 1472 | latitude: 35.120715, 1473 | longitude: -89.849395 1474 | }, 1475 | { 1476 | areaCode: 903, 1477 | latitude: 33.012011111111, 1478 | longitude: -95.526376666667 1479 | }, 1480 | { 1481 | areaCode: 904, 1482 | latitude: 30.252276666667, 1483 | longitude: -81.605646666667 1484 | }, 1485 | { 1486 | areaCode: 907, 1487 | latitude: 60.268353333333, 1488 | longitude: -141.136315 1489 | }, 1490 | { 1491 | areaCode: 908, 1492 | latitude: 40.662897272727, 1493 | longitude: -74.289773636364 1494 | }, 1495 | { 1496 | areaCode: 909, 1497 | latitude: 34.074295, 1498 | longitude: -117.54055722222 1499 | }, 1500 | { 1501 | areaCode: 910, 1502 | latitude: 34.757956, 1503 | longitude: -78.453958 1504 | }, 1505 | { 1506 | areaCode: 912, 1507 | latitude: 32.126403333333, 1508 | longitude: -81.492983333333 1509 | }, 1510 | { 1511 | areaCode: 913, 1512 | latitude: 39.0303175, 1513 | longitude: -94.718015 1514 | }, 1515 | { 1516 | areaCode: 914, 1517 | latitude: 41.026625, 1518 | longitude: -73.8051675 1519 | }, 1520 | { 1521 | areaCode: 915, 1522 | latitude: 31.70664, 1523 | longitude: -106.39512 1524 | }, 1525 | { 1526 | areaCode: 916, 1527 | latitude: 38.540042352941, 1528 | longitude: -121.39831588235 1529 | }, 1530 | { 1531 | areaCode: 917, 1532 | latitude: 40.695385, 1533 | longitude: -73.967385 1534 | }, 1535 | { 1536 | areaCode: 918, 1537 | latitude: 36.1754425, 1538 | longitude: -95.783525 1539 | }, 1540 | { 1541 | areaCode: 919, 1542 | latitude: 35.726142857143, 1543 | longitude: -78.77469 1544 | }, 1545 | { 1546 | areaCode: 920, 1547 | latitude: 43.948076, 1548 | longitude: -88.281434 1549 | }, 1550 | { 1551 | areaCode: 925, 1552 | latitude: 37.891776666667, 1553 | longitude: -121.935052 1554 | }, 1555 | { 1556 | areaCode: 928, 1557 | latitude: 34.3190475, 1558 | longitude: -113.5519425 1559 | }, 1560 | { 1561 | areaCode: 929, 1562 | latitude: 40.691608, 1563 | longitude: -73.959668 1564 | }, 1565 | { 1566 | areaCode: 930, 1567 | latitude: 38.66688, 1568 | longitude: -86.391257142857 1569 | }, 1570 | { 1571 | areaCode: 931, 1572 | latitude: 36.10256, 1573 | longitude: -86.632123333333 1574 | }, 1575 | { 1576 | areaCode: 936, 1577 | latitude: 30.99429, 1578 | longitude: -95.097855 1579 | }, 1580 | { 1581 | areaCode: 937, 1582 | latitude: 39.854266923077, 1583 | longitude: -84.115557692308 1584 | }, 1585 | { 1586 | areaCode: 940, 1587 | latitude: 33.391593333333, 1588 | longitude: -97.54021 1589 | }, 1590 | { 1591 | areaCode: 941, 1592 | latitude: 27.202133333333, 1593 | longitude: -82.344351666667 1594 | }, 1595 | { 1596 | areaCode: 947, 1597 | latitude: 42.543724615385, 1598 | longitude: -83.262428461538 1599 | }, 1600 | { 1601 | areaCode: 949, 1602 | latitude: 33.573486153846, 1603 | longitude: -117.73371615385 1604 | }, 1605 | { 1606 | areaCode: 951, 1607 | latitude: 33.819667, 1608 | longitude: -117.258786 1609 | }, 1610 | { 1611 | areaCode: 952, 1612 | latitude: 44.838195, 1613 | longitude: -93.4117575 1614 | }, 1615 | { 1616 | areaCode: 954, 1617 | latitude: 26.143138421053, 1618 | longitude: -80.201925263158 1619 | }, 1620 | { 1621 | areaCode: 956, 1622 | latitude: 26.299599, 1623 | longitude: -98.138061 1624 | }, 1625 | { 1626 | areaCode: 959, 1627 | latitude: 41.674009166667, 1628 | longitude: -72.641041666667 1629 | }, 1630 | { 1631 | areaCode: 970, 1632 | latitude: 40.11755, 1633 | longitude: -105.854795 1634 | }, 1635 | { 1636 | areaCode: 971, 1637 | latitude: 45.347859333333, 1638 | longitude: -122.79654266667 1639 | }, 1640 | { 1641 | areaCode: 972, 1642 | latitude: 32.869057826087, 1643 | longitude: -96.826015652174 1644 | }, 1645 | { 1646 | areaCode: 973, 1647 | latitude: 40.834617368421, 1648 | longitude: -74.219415789474 1649 | }, 1650 | { 1651 | areaCode: 978, 1652 | latitude: 42.603682857143, 1653 | longitude: -71.226208571429 1654 | }, 1655 | { 1656 | areaCode: 979, 1657 | latitude: 30.112066666667, 1658 | longitude: -96.046253333333 1659 | }, 1660 | { 1661 | areaCode: 980, 1662 | latitude: 35.367685555556, 1663 | longitude: -80.747737777778 1664 | }, 1665 | { 1666 | areaCode: 984, 1667 | latitude: 35.726142857143, 1668 | longitude: -78.77469 1669 | }, 1670 | { 1671 | areaCode: 985, 1672 | latitude: 29.979313333333, 1673 | longitude: -90.32739 1674 | }, 1675 | { 1676 | areaCode: 989, 1677 | latitude: 43.52936, 1678 | longitude: -84.16104 1679 | }, 1680 | { 1681 | areaCode: 854, 1682 | latitude: 33.0913225, 1683 | longitude: -79.901955 1684 | } 1685 | ] 1686 | } 1687 | 1688 | export default areaCodeGeos 1689 | -------------------------------------------------------------------------------- /scripts/preparePhoneNumberMap.ts: -------------------------------------------------------------------------------- 1 | import * as fsp from 'fs/promises' 2 | import path from 'path' 3 | import areaCodeGeos from './areaCodeGeos' 4 | 5 | /**************************************************** 6 | Formats a text list of phone numbers (in src/data/phone-numbers.txt) into the PhoneNumberMap 7 | structure. 8 | ****************************************************/ 9 | 10 | async function preparePhoneNumberMap () { 11 | const caAreaCodes = areaCodeGeos.ca.map(({ areaCode }) => areaCode) 12 | const usAreaCodes = areaCodeGeos.us.map(({ areaCode }) => areaCode) 13 | 14 | const phoneNumberList = ( 15 | await fsp.readFile( 16 | path.join(__dirname, '../src/data/phoneNumbers.txt'), 17 | 'utf8' 18 | ) 19 | ).split('\n') 20 | 21 | const phoneNumberPoolMap = { 22 | ca: {}, 23 | us: {} 24 | } 25 | 26 | for (const phoneNumber of phoneNumberList) { 27 | const { countryCode, areaCode } = 28 | phoneNumber.match( 29 | /^\s*(?:\+?(?\d{1,3}))?[-. (]*(?\d{3})[-. )]*(?\d{3})[-. ]*(?\d{4})(?: *x(\d+))?\s*$/ 30 | ).groups 31 | 32 | if (!areaCode) throw Error(`${phoneNumber} is not valid E.164 format`) 33 | 34 | // Check if area code is Canadian 35 | if (caAreaCodes.includes(parseInt(areaCode))) { 36 | // If the area code has an entry in our number pool output... 37 | if (phoneNumberPoolMap.ca[areaCode]) { 38 | // Push the number into the existing array 39 | phoneNumberPoolMap.ca[areaCode].push(phoneNumber) 40 | // Otherwise create a new entry and array 41 | } else phoneNumberPoolMap.ca[areaCode] = [phoneNumber] 42 | } else if (usAreaCodes.includes(parseInt(areaCode))) { 43 | if (phoneNumberPoolMap.us[areaCode]) { 44 | phoneNumberPoolMap.us[areaCode].push(phoneNumber) 45 | } else phoneNumberPoolMap.us[areaCode] = [phoneNumber] 46 | } else { 47 | if (phoneNumberPoolMap[countryCode]) { 48 | phoneNumberPoolMap[countryCode].push(phoneNumber) 49 | } else phoneNumberPoolMap[countryCode] = [phoneNumber] 50 | } 51 | } 52 | 53 | await fsp.writeFile( 54 | path.resolve(__dirname, '../src/data/phoneNumberMap.json'), 55 | JSON.stringify(phoneNumberPoolMap) 56 | ) 57 | } 58 | 59 | preparePhoneNumberMap() 60 | -------------------------------------------------------------------------------- /src/@types/types.ts: -------------------------------------------------------------------------------- 1 | import type { Request } from 'express' 2 | import { ConversationListInstanceCreateOptions } from 'twilio/lib/rest/conversations/v1/conversation' 3 | 4 | export interface SessionPostBody extends ConversationListInstanceCreateOptions { 5 | addresses: Array 6 | } 7 | 8 | export interface ConversationsPostEventBody { 9 | AccountSid: string 10 | Attributes: string 11 | ChatServiceSid: string 12 | ConversationSid: string 13 | DateCreated: string 14 | DateUpdated: string 15 | EventType: string 16 | MessagingServiceSid: string 17 | RetryCount: string 18 | Source: string 19 | State: string 20 | } 21 | 22 | export interface ActiveProxyAddresses { 23 | [key: string]: Array 24 | } 25 | 26 | export interface ProxyBindings { 27 | [key: string]: Array 28 | } 29 | 30 | export interface ConversationParticipant { 31 | 'messagingBinding.address': string 32 | 'messagingBinding.proxyAddress': string 33 | } 34 | 35 | export interface ParticipantToDial { 36 | address: string 37 | proxyAddress: string 38 | } 39 | 40 | // TODO: Determine should phone numbers be stored as a string or an object? 41 | export interface PhoneNumberMap { 42 | ca: { [key: number | string]: string[] } 43 | us: { [key: number | string]: string[] } 44 | } 45 | -------------------------------------------------------------------------------- /src/app/app.ts: -------------------------------------------------------------------------------- 1 | import cors from 'cors' 2 | import express from 'express' 3 | import createError from 'http-errors' 4 | import logger from 'morgan' 5 | import bodyParser from 'body-parser' 6 | import router from '../routes' 7 | 8 | 9 | export const app = express() 10 | 11 | /**************************************************** 12 | Apply Middleware 13 | ****************************************************/ 14 | if (app.get('env') === 'development') { 15 | app.use(cors({origin: '*'})) 16 | app.use(logger('dev')) 17 | } 18 | 19 | app.use(bodyParser.json()) 20 | app.use(express.json()) 21 | app.use(express.urlencoded({extended: true})) 22 | 23 | app.use(router) 24 | 25 | /**************************************************** 26 | Apply Routes 27 | ****************************************************/ 28 | app.use((req, res, next) => next(createError(404))) // throw 404 if route not found 29 | -------------------------------------------------------------------------------- /src/config/phoneBatchSize.ts: -------------------------------------------------------------------------------- 1 | // Configure how many phone numbers are 2 | // retrieved per participant added to a conversation. 3 | 4 | // If you have many concurrent conversations, you may 5 | // want to increase this number. 6 | 7 | export const phoneBatchSize = 50 8 | -------------------------------------------------------------------------------- /src/config/retry.config.ts: -------------------------------------------------------------------------------- 1 | export const retryConfig = { 2 | retries: 5, 3 | factor: 2, 4 | minTimeout: 1000, 5 | maxTimeout: 360000 6 | } 7 | -------------------------------------------------------------------------------- /src/config/webhookValidationConfig.ts: -------------------------------------------------------------------------------- 1 | function shouldValidate () { 2 | return process.env.NODE_ENV !== 'test' 3 | } 4 | 5 | export const webhookConfig = { 6 | protocol: 'https', 7 | host: process.env.DOMAIN, 8 | validate: shouldValidate() 9 | } 10 | -------------------------------------------------------------------------------- /src/controllers/conversationsPostEvent.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from 'express' 2 | import { ConversationsPostEventBody } from '../@types/types' 3 | import { deleteConversation } from '../utils/deleteConversation.util' 4 | 5 | export const post = async ( 6 | req: Request<{}, {}, ConversationsPostEventBody>, 7 | res: Response 8 | ) => { 9 | const { 10 | EventType: eventType, 11 | State: state, 12 | ConversationSid: conversationSid 13 | } = req.body 14 | 15 | if (eventType === 'onConversationUpdated' && state === 'closed') { 16 | try { 17 | await deleteConversation(conversationSid) 18 | return res.status(200).send(`${conversationSid} deleted`) 19 | } catch (err) { 20 | return res.status(500).send(`${conversationSid} failed to delete: ${err}`) 21 | } 22 | } 23 | return res.status(200).send('not processed') 24 | } 25 | -------------------------------------------------------------------------------- /src/controllers/inboundCall.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from 'express' 2 | import { generateTwiml } from '../services/inboundCall.service' 3 | 4 | export const post = async ( 5 | req: Request, 6 | res: Response 7 | ) => { 8 | const from = req.body.From 9 | const to = req.body.Called 10 | 11 | const twiml = await generateTwiml(from, to) 12 | 13 | res.set('Content-Type', 'text/xml') 14 | res.send(twiml.toString()) 15 | } 16 | -------------------------------------------------------------------------------- /src/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export * as conversationsPostEvent from './conversationsPostEvent' 2 | export * as inboundCall from './inboundCall' 3 | export * as session from './session' 4 | -------------------------------------------------------------------------------- /src/controllers/session.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from 'express' 2 | import { SessionPostBody } from '../@types/types' 3 | 4 | import { 5 | getActiveProxyAddresses, 6 | matchAvailableProxyAddresses, 7 | addParticipantsToConversation 8 | } from '../services/session.service' 9 | 10 | import { createConversation, deleteConversation } from '../utils' 11 | 12 | export const post = async ( 13 | req: Request<{}, {}, SessionPostBody>, 14 | res: Response 15 | ) => { 16 | try { 17 | const phoneNumbers = req.body.addresses 18 | const activeProxyAddresses = await getActiveProxyAddresses(phoneNumbers) 19 | const proxyAddresses = await matchAvailableProxyAddresses(activeProxyAddresses) 20 | const conversation = await createConversation(req.body) 21 | 22 | try { 23 | await addParticipantsToConversation(conversation.sid, proxyAddresses) 24 | res.setHeader('content-type', 'application/json') 25 | return res.status(200).send(conversation) 26 | } catch (err) { 27 | await deleteConversation(conversation.sid) 28 | return res.status(500).send(`${conversation.sid} failed to create session: ${err}`) 29 | } 30 | } catch (err) { 31 | return res.status(500).send(`Failed to create session: ${err}`) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/data/phoneNumberMap.json: -------------------------------------------------------------------------------- 1 | {"ca":{},"us":{"442":["+14422614750"],"978":["+19784901134"]}} -------------------------------------------------------------------------------- /src/data/phoneNumbers.txt: -------------------------------------------------------------------------------- 1 | +19784901134 2 | +14422614750 -------------------------------------------------------------------------------- /src/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | // @todo: webhook validator middleware 2 | -------------------------------------------------------------------------------- /src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import * as controllers from '../controllers' 3 | 4 | import twilio from 'twilio' 5 | import { webhookConfig } from '../config/webhookValidationConfig' 6 | 7 | import basicAuth from 'express-basic-auth' 8 | const { AUTH_USERNAME, AUTH_PASSWORD } = process.env 9 | 10 | const router = Router() 11 | 12 | router.post('/conversations-post-event', twilio.webhook(webhookConfig), controllers.conversationsPostEvent.post) 13 | 14 | router.post('/inbound-call', twilio.webhook(webhookConfig), controllers.inboundCall.post) 15 | 16 | router.post('/sessions', basicAuth({ 17 | users: { [AUTH_USERNAME]: AUTH_PASSWORD } 18 | }), controllers.session.post) 19 | 20 | export default router 21 | -------------------------------------------------------------------------------- /src/services/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio-labs/masked-communications-app/6af4e04d9ec3f3152e0b5ed18a4503716468d7c0/src/services/.DS_Store -------------------------------------------------------------------------------- /src/services/geoRouter.service.ts: -------------------------------------------------------------------------------- 1 | import phoneNumberMap from '../data/phoneNumberMap.json' 2 | import areaCodeProximityMap from '../data/areaCodeProximityMap.json' 3 | import phoneNumberParser from '../utils/phoneNumberParser' 4 | import areaCodeGeos from '../../scripts/areaCodeGeos' 5 | 6 | /** 7 | * @function getNearbyAreaCodeNumbers 8 | * 9 | * @description getNearbyAreaCodeNumbers returns an array of phone numbers ordered by their proximity 10 | * to the area code argument. The results are paged which allows you to call iterate through 11 | * your phone number pool efficiently. 12 | * @param phoneNumber - the phone number containing area code you want to match 13 | * @param from - starting point to return 14 | * @param pageSize - how many phone numbers 15 | * @returns array of phone numbers ordered by their proximity to the area code argument 16 | */ 17 | 18 | export function getNearbyAreaCodeNumbers ( 19 | phoneNumber: string, 20 | from: number = 0, 21 | pageSize: number = 50 22 | ) { 23 | // array of area codes ordered by their proximity to the areaCode argument 24 | const country = isCanadianNumber(phoneNumber) ? 'ca' : 'us' 25 | const { area } = phoneNumberParser(phoneNumber) 26 | 27 | // TODO: refactor this. It's currently being used to un-link 28 | // array references in the number map and proximit map. 29 | // If we don't unlink, values between function runs get 30 | // mixed up / shifted out of the array and we don't get any results back. 31 | const numberMap = JSON.parse(JSON.stringify(phoneNumberMap)) 32 | const proximityMap = JSON.parse(JSON.stringify(areaCodeProximityMap)) 33 | 34 | const areaCodesByProximity: number[] | string[] = 35 | proximityMap[country][area] 36 | 37 | const countryPhoneMap = numberMap[country] 38 | 39 | const optimalPhoneNumbers = [] 40 | let curAreaCode = areaCodesByProximity.shift() 41 | while (optimalPhoneNumbers.length < from + pageSize && !!curAreaCode) { 42 | if (!countryPhoneMap[curAreaCode]?.length) { 43 | curAreaCode = areaCodesByProximity.shift() 44 | continue 45 | } 46 | 47 | optimalPhoneNumbers.push(countryPhoneMap[curAreaCode].shift()) 48 | } 49 | 50 | return optimalPhoneNumbers.slice(from, from + pageSize) 51 | } 52 | 53 | export function isCanadianNumber (phoneNumber: string): boolean { 54 | const { area } = phoneNumberParser(phoneNumber) 55 | const canadianAreaCodes = areaCodeGeos.ca 56 | 57 | const matchingCode = canadianAreaCodes.filter((ac) => { 58 | return String(area) === String(ac.areaCode) 59 | }) 60 | 61 | return matchingCode.length > 0 62 | } 63 | 64 | export function getNumberByCountry ( 65 | countryCode: keyof typeof phoneNumberMap, 66 | from: number = 0, 67 | pageSize: number = 50 68 | ): string[] { 69 | return Object.values(phoneNumberMap[countryCode]).flat(1).slice(from, from + pageSize) 70 | } 71 | 72 | export function geoRouter ( 73 | phoneNumber: string, 74 | from: number = 0, 75 | pageSize: number = 50 76 | ): string[] { 77 | const parsedNumber = phoneNumberParser(phoneNumber) 78 | 79 | if (parsedNumber.country === '1') { 80 | return getNearbyAreaCodeNumbers(phoneNumber, from, pageSize) 81 | } 82 | 83 | return getNumberByCountry(parsedNumber.country, from, pageSize) 84 | } 85 | -------------------------------------------------------------------------------- /src/services/inboundCall.service.ts: -------------------------------------------------------------------------------- 1 | import client from '../twilioClient' 2 | import VoiceResponse from 'twilio/lib/twiml/VoiceResponse' 3 | 4 | import { 5 | getConversationByAddressPair, 6 | participantsToDial, 7 | listConversationParticipants, 8 | generateConferenceName 9 | } from '../utils/' 10 | 11 | export function joinConferenceTwiml (conferenceName: string) : VoiceResponse { 12 | const response = new VoiceResponse() 13 | const dial = response.dial() 14 | dial.conference(`${decodeURIComponent(conferenceName)}`) 15 | 16 | return response 17 | } 18 | 19 | export const generateTwiml = async (from: string, to: string) => { 20 | const response = new VoiceResponse() 21 | 22 | const conversation = await getConversationByAddressPair(from, to) 23 | 24 | if (!conversation) { 25 | console.log(`No active session (conversation) for ${from}.`) 26 | response.say({ 27 | voice: process.env.CALL_ANNOUCEMENT_VOICE as any, 28 | language: process.env.CALL_ANNOUCEMENT_LANGUAGE as any 29 | }, process.env.OUT_OF_SESSION_MESSAGE_FOR_CALL) 30 | } else { 31 | const participants = await listConversationParticipants(conversation.conversationSid) 32 | const dialList = participantsToDial(participants, from) 33 | 34 | response.say({ 35 | voice: process.env.CALL_ANNOUCEMENT_VOICE as any, 36 | language: process.env.CALL_ANNOUCEMENT_LANGUAGE as any 37 | }, process.env.CONNECTING_CALL_ANNOUCEMENT) 38 | 39 | if (dialList.length > 1) { 40 | const conferenceName = generateConferenceName(from) 41 | 42 | const callPromises = dialList.map(pa => { 43 | console.log(`Dialing ${pa.address} from ${pa.proxyAddress}...`) 44 | 45 | return client.calls.create({ 46 | to: pa.address, 47 | from: pa.proxyAddress, 48 | twiml: joinConferenceTwiml(conferenceName).toString() 49 | }) 50 | }) 51 | 52 | await Promise.all(callPromises) 53 | 54 | const dial = response.dial() 55 | dial.conference({ 56 | endConferenceOnExit: true 57 | }, conferenceName) 58 | } else { 59 | const callee = dialList[0] 60 | const dial = response.dial({ 61 | callerId: callee.proxyAddress 62 | }) 63 | 64 | dial.number(callee.address) 65 | } 66 | } 67 | 68 | return response 69 | } 70 | -------------------------------------------------------------------------------- /src/services/session.service.ts: -------------------------------------------------------------------------------- 1 | import { ActiveProxyAddresses, ProxyBindings } from '../@types/types' 2 | import { listParticipantConversations, retryAddParticipant } from '../utils' 3 | import { geoRouter } from './geoRouter.service' 4 | import { phoneBatchSize } from '../config/phoneBatchSize' 5 | 6 | export const getActiveProxyAddresses = async (phoneNumbers: Array) : Promise => { 7 | const activeConversations = {} 8 | 9 | const promises = phoneNumbers.map(async (phoneNumber: string) => { 10 | // listParticipantConversations returns ALL conversations, closed and active. 11 | const participantConversations = await listParticipantConversations(phoneNumber) 12 | 13 | // We want to ignore closed conversations since we cant use conversations once they're closed. 14 | // We can use conversations that are active or inactive 15 | const participantActiveConversations = participantConversations.filter((participant) => { 16 | return participant.conversationState !== 'closed' 17 | }) 18 | // Let's get all the proxy addresses that currently being used in the active conversations 19 | const proxyAddresses = participantActiveConversations.map((participant) => { 20 | return participant.participantMessagingBinding.proxy_address 21 | }) 22 | 23 | activeConversations[phoneNumber] = proxyAddresses 24 | return activeConversations 25 | }) 26 | 27 | try { 28 | await Promise.all(promises) 29 | return activeConversations 30 | } catch (err) { 31 | console.log(err) 32 | throw new Error(err) 33 | } 34 | } 35 | 36 | export function pullNumbers (userPhone: string, activeAddresses: String[], from: number) { 37 | const phoneNumbers: Array = geoRouter(userPhone, from, phoneBatchSize) 38 | console.log({ userPhone }, { activeAddresses }, { phoneNumbers }, { from }) 39 | if (phoneNumbers.length === 0) { 40 | throw new Error(`Not enough numbers available in pool for ${userPhone}`) 41 | } 42 | 43 | const availableNumbers = phoneNumbers.filter((pn) => { 44 | return !activeAddresses.includes(pn) 45 | }) 46 | 47 | console.log({ availableNumbers }) 48 | 49 | if (availableNumbers.length < 1) { 50 | return pullNumbers(userPhone, activeAddresses, from + phoneBatchSize) 51 | } 52 | 53 | return { [userPhone]: availableNumbers } 54 | } 55 | 56 | export const matchAvailableProxyAddresses = async (activeProxyAddresses: ActiveProxyAddresses) : Promise => { 57 | try { 58 | let proxyBindings = {} 59 | 60 | for (const [userPhone, activeAddresses] of Object.entries(activeProxyAddresses)) { 61 | const newBindings = pullNumbers(userPhone, activeAddresses, 0) 62 | proxyBindings = Object.assign(proxyBindings, newBindings) 63 | } 64 | 65 | return proxyBindings 66 | } catch (err) { 67 | console.log(err) 68 | throw Error(err) 69 | } 70 | } 71 | 72 | export const addParticipantsToConversation = async (conversationSid: string, proxyBindings: ProxyBindings) => { 73 | const promises = [] 74 | 75 | for (const [participantAddress, proxyAddresses] of Object.entries(proxyBindings)) { 76 | const participantAttempt = retryAddParticipant(conversationSid, participantAddress, proxyAddresses) 77 | promises.push(participantAttempt) 78 | } 79 | 80 | try { 81 | const results = await Promise.all(promises) 82 | return results 83 | } catch (err) { 84 | console.log(err) 85 | throw new Error(err) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/twilioClient.ts: -------------------------------------------------------------------------------- 1 | import twilio from "twilio"; 2 | 3 | let TWILIO_ACCOUNT_SID 4 | let TWILIO_AUTH_TOKEN 5 | 6 | if (process.env.NODE_ENV === "test") { 7 | TWILIO_ACCOUNT_SID = "ACabc" 8 | TWILIO_AUTH_TOKEN = "123" 9 | } else { 10 | TWILIO_ACCOUNT_SID = process.env.TWILIO_ACCOUNT_SID 11 | TWILIO_AUTH_TOKEN = process.env. TWILIO_AUTH_TOKEN 12 | } 13 | 14 | 15 | if (!TWILIO_ACCOUNT_SID || !TWILIO_AUTH_TOKEN) { 16 | throw Error( 17 | "TWILIO_ACCOUNT_SID &/or TWILIO_AUTH_TOKEN are missing from environment variables." 18 | ); 19 | } 20 | 21 | const client = twilio( 22 | TWILIO_ACCOUNT_SID, 23 | TWILIO_AUTH_TOKEN 24 | ); 25 | 26 | export default client; 27 | -------------------------------------------------------------------------------- /src/utils/addParticipant.util.ts: -------------------------------------------------------------------------------- 1 | import retry from 'async-retry'; 2 | import client from "../twilioClient"; 3 | import { ParticipantInstance, ParticipantListInstanceCreateOptions } from "twilio/lib/rest/conversations/v1/conversation/participant"; 4 | import { retryConfig } from '../config/retry.config'; 5 | 6 | export const addParticipant = async ( 7 | conversationSid: string, 8 | participant: ParticipantListInstanceCreateOptions, 9 | retryOptions = retryConfig 10 | ) : Promise => { 11 | return retry(async (quit) => { 12 | try { 13 | const createdParticipant = await client.conversations 14 | .conversations(conversationSid) 15 | .participants 16 | .create(participant) 17 | 18 | return createdParticipant 19 | } catch (err) { 20 | if (err.status !== 429) { 21 | console.log('Quit without retry') 22 | quit(new Error(err)); 23 | return; 24 | } 25 | 26 | console.log('Re-trying on 429 error'); 27 | throw new Error(err); 28 | } 29 | }, retryOptions) 30 | } -------------------------------------------------------------------------------- /src/utils/createConversation.util.ts: -------------------------------------------------------------------------------- 1 | import { ConversationInstance } from 'twilio/lib/rest/conversations/v1/conversation' 2 | import { SessionPostBody } from '../@types/types' 3 | import client from '../twilioClient' 4 | 5 | import retry from 'async-retry' 6 | import { retryConfig } from '../config/retry.config' 7 | 8 | export const createConversation = async (options: SessionPostBody, retryOptions = retryConfig) : Promise => { 9 | return retry(async (quit) => { 10 | try { 11 | return client.conversations.conversations.create(options) 12 | } catch (err) { 13 | if (err.status !== 429) { 14 | console.log('Quit without retry') 15 | console.log(err) 16 | quit(new Error('Quit without retry')) 17 | return 18 | } 19 | 20 | console.log('Re-trying on 429 error') 21 | throw new Error(err) 22 | } 23 | }, retryOptions) 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/deleteConversation.util.ts: -------------------------------------------------------------------------------- 1 | import client from "../twilioClient" 2 | 3 | import retry from 'async-retry' 4 | import { retryConfig } from "../config/retry.config" 5 | 6 | export const deleteConversation = async (conversationSid: string, retryOptions = retryConfig) : Promise => { 7 | return retry(async(quit) => { 8 | try { 9 | await client.conversations.conversations(conversationSid).remove() 10 | return true 11 | } catch (err) { 12 | if (err.status !== 429) { 13 | console.log('Quit without retry') 14 | quit(new Error(err)); 15 | return; 16 | } 17 | 18 | console.log('Re-trying on 429 error'); 19 | throw new Error(err); 20 | } 21 | }, retryOptions) 22 | } -------------------------------------------------------------------------------- /src/utils/generateConferenceName.util.ts: -------------------------------------------------------------------------------- 1 | export const generateConferenceName = (phoneNumber: string) : string => { 2 | return `${phoneNumber}_at_${Date.now()}` 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/getConversationByAddressPair.util.ts: -------------------------------------------------------------------------------- 1 | import { ParticipantConversationInstance } from 'twilio/lib/rest/conversations/v1/participantConversation' 2 | import retry from 'async-retry' 3 | import client from '../twilioClient' 4 | import { retryConfig } from '../config/retry.config' 5 | 6 | export const getConversationByAddressPair = async (address: string, proxyAddress: string, retryOptions = retryConfig) : Promise => { 7 | return retry(async (quit) => { 8 | try { 9 | const participantConversations = await client.conversations.v1 10 | .participantConversations 11 | .list({ address }) 12 | 13 | const conversation = participantConversations.find(p => { 14 | return p.conversationState !== 'closed' && p.participantMessagingBinding.proxy_address === proxyAddress 15 | }) 16 | return conversation 17 | } catch (err) { 18 | if (err.status !== 429) { 19 | quit(new Error(err)) 20 | return 21 | } 22 | 23 | console.log('Re-trying on 429 error') 24 | throw new Error(err) 25 | } 26 | }, retryOptions) 27 | } 28 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './addParticipant.util' 2 | export * from './createConversation.util' 3 | export * from './deleteConversation.util' 4 | export * from './listParticipantConversations.util' 5 | export * from './retryAddParticipant.util' 6 | export * from './listConversationParticipants.util' 7 | export * from './participantsToDial.util' 8 | export * from './getConversationByAddressPair.util' 9 | export * from './generateConferenceName.util' 10 | -------------------------------------------------------------------------------- /src/utils/listConversationParticipants.util.ts: -------------------------------------------------------------------------------- 1 | import retry from 'async-retry' 2 | import { ParticipantInstance } from 'twilio/lib/rest/conversations/v1/conversation/participant' 3 | import client from '../twilioClient' 4 | import { retryConfig } from '../config/retry.config' 5 | 6 | export const listConversationParticipants = async (conversation: string, retryOptions = retryConfig) : Promise => { 7 | return retry(async (quit) => { 8 | try { 9 | const participants = await client.conversations 10 | .conversations(conversation) 11 | .participants 12 | .list() 13 | 14 | return participants 15 | } catch (err) { 16 | if (err.status !== 429) { 17 | quit(new Error(err)) 18 | return 19 | } 20 | 21 | console.log('Re-trying on 429 error') 22 | throw new Error(err) 23 | } 24 | }, retryOptions) 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/listParticipantConversations.util.ts: -------------------------------------------------------------------------------- 1 | import retry from 'async-retry' 2 | import { ParticipantConversationInstance } from 'twilio/lib/rest/conversations/v1/participantConversation' 3 | import client from '../twilioClient' 4 | import { retryConfig } from "../config/retry.config"; 5 | 6 | export const listParticipantConversations = async (phoneNumber: string, retryOptions = retryConfig) : Promise => { 7 | return retry(async (quit) => { 8 | try { 9 | const activeConversations = await client.conversations.participantConversations.list({address: phoneNumber}) 10 | return activeConversations 11 | } catch (err) { 12 | if (err.status !== 429) { 13 | console.log('Quit without retry') 14 | console.log(err) 15 | quit(new Error('Quit without retry')); 16 | return; 17 | } 18 | 19 | console.log('Re-trying on 429 error'); 20 | throw new Error(err); 21 | } 22 | }, retryOptions) 23 | } -------------------------------------------------------------------------------- /src/utils/participantsToDial.util.ts: -------------------------------------------------------------------------------- 1 | import { ParticipantInstance } from 'twilio/lib/rest/conversations/v1/conversation/participant' 2 | import { ParticipantToDial } from '../@types/types' 3 | 4 | export const participantsToDial = (participants: Array, from: string) : ParticipantToDial[] => { 5 | const output = participants.reduce((result, p) => { 6 | if (p.messagingBinding.type === 'sms' && p.messagingBinding.address !== from) { 7 | console.log(`Adding ${p.messagingBinding.address} to list of numbers to dial.\n`) 8 | 9 | result.push({ 10 | address: p.messagingBinding.address, 11 | proxyAddress: p.messagingBinding.proxy_address 12 | }) 13 | } 14 | 15 | return result 16 | }, []) 17 | 18 | return output 19 | } 20 | -------------------------------------------------------------------------------- /src/utils/phoneNumberParser.ts: -------------------------------------------------------------------------------- 1 | export default function phoneNumberParser (phoneNumber) { 2 | const result = phoneNumber.match( 3 | /^\s*(?:\+?(?\d{1,3}))?[-. (]*(?\d{3})[-. )]*(?\d{3})[-. ]*(?\d{4})(?: *x(\d+))?\s*$/ 4 | ).groups 5 | 6 | return result 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/retryAddParticipant.util.ts: -------------------------------------------------------------------------------- 1 | import { addParticipant } from "./addParticipant.util" 2 | 3 | export async function retryAddParticipant(conversationSid: string, participantAddress: string, proxyAddresses: Array) { 4 | if(proxyAddresses.length < 1) { 5 | throw new Error(`No available proxy addresses for ${participantAddress}`) 6 | } 7 | 8 | try { 9 | const participant = { 10 | 'messagingBinding.address': participantAddress, 11 | 'messagingBinding.proxyAddress': proxyAddresses[0] 12 | } as any 13 | 14 | const result = await addParticipant(conversationSid, participant) 15 | return result 16 | } catch(err) { 17 | if (err.code === 50416) { 18 | proxyAddresses.shift() 19 | return retryAddParticipant(conversationSid, participantAddress, proxyAddresses) 20 | } else { 21 | console.log(err) 22 | throw new Error(err) 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /tests/.jest/setEnvVars.js: -------------------------------------------------------------------------------- 1 | process.env.NUMBER_POOL = '["+3334445555", "+4445556666", "+5556667777", "+6667778888"]' 2 | process.env.TWILIO_ACCOUNT_SID = 'ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' 3 | process.env.TWILIO_AUTH_TOKEN = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' 4 | process.env.AUTH_USERNAME = 'testUser' 5 | process.env.AUTH_PASSWORD = 'testPassword' 6 | process.env.AUTH_HEADER = 'Basic dGVzdFVzZXI6dGVzdFBhc3N3b3Jk' -------------------------------------------------------------------------------- /tests/controllers/conversationsPostEvent.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import {app} from '../../src/app/app' 3 | import * as DeleteConversation from '../../src/utils/deleteConversation.util' 4 | 5 | describe('conversations post event controller', () => { 6 | jest.setTimeout(60000) 7 | 8 | beforeEach(() => { 9 | jest.resetAllMocks() 10 | }) 11 | 12 | // Test parameters 13 | const eventTypeOnConversationUpdated = 'onConversationUpdated' 14 | const eventTypeOther = 'otherEvent123' 15 | const closedState = 'closed' 16 | const otherState = 'state123' 17 | const conversationSid = 'CHXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' 18 | 19 | it('should delete conversation', async () => { 20 | const deleteConversationSpy = jest 21 | .spyOn(DeleteConversation, 'deleteConversation') 22 | .mockResolvedValue(true) 23 | 24 | const res = await request(app) 25 | .post('/conversations-post-event') 26 | .set('content-type', 'application/json') 27 | .send({ 28 | EventType: eventTypeOnConversationUpdated, 29 | State: closedState, 30 | ConversationSid: conversationSid, 31 | }) 32 | 33 | expect(res.status).toEqual(200) 34 | expect(res.text).toEqual(`${conversationSid} deleted`) 35 | expect(deleteConversationSpy).toBeCalledWith(conversationSid) 36 | }) 37 | 38 | it('should ignore if status != closed', async () => { 39 | const deleteConversationSpy = jest 40 | .spyOn(DeleteConversation, 'deleteConversation') 41 | .mockResolvedValue(true) 42 | 43 | const res = await request(app) 44 | .post('/conversations-post-event') 45 | .set('content-type', 'application/json') 46 | .send({ 47 | EventType: eventTypeOnConversationUpdated, 48 | State: otherState, 49 | ConversationSid: conversationSid, 50 | }) 51 | 52 | expect(res.status).toEqual(200) 53 | expect(res.text).toEqual('not processed') 54 | expect(deleteConversationSpy).toBeCalledTimes(0) 55 | }) 56 | 57 | it('should ignore if eventType != onConversationUpdated', async () => { 58 | const deleteConversationSpy = jest 59 | .spyOn(DeleteConversation, 'deleteConversation') 60 | .mockResolvedValue(true) 61 | 62 | const res = await request(app) 63 | .post('/conversations-post-event') 64 | .set('content-type', 'application/json') 65 | .send({ 66 | EventType: eventTypeOther, 67 | State: closedState, 68 | ConversationSid: conversationSid, 69 | }) 70 | 71 | expect(res.status).toEqual(200) 72 | expect(res.text).toEqual('not processed') 73 | expect(deleteConversationSpy).toBeCalledTimes(0) 74 | }) 75 | 76 | it('should return 500 if throws', async () => { 77 | const errorCode = 'ErrorCode123' 78 | 79 | const deleteConversationSpy = jest 80 | .spyOn(DeleteConversation, 'deleteConversation') 81 | .mockRejectedValue(new Error(errorCode)) 82 | 83 | const res = await request(app) 84 | .post('/conversations-post-event') 85 | .set('content-type', 'application/json') 86 | .send({ 87 | EventType: eventTypeOnConversationUpdated, 88 | State: closedState, 89 | ConversationSid: conversationSid, 90 | }) 91 | 92 | expect(res.status).toEqual(500) 93 | expect(res.text).toEqual(`${conversationSid} failed to delete: Error: ${errorCode}`) 94 | expect(deleteConversationSpy).toBeCalledWith(conversationSid) 95 | }) 96 | }) 97 | -------------------------------------------------------------------------------- /tests/controllers/inboundCall.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import * as InboundCallService from '../../src/services/inboundCall.service' 3 | import { app } from '../../src/app/app' 4 | import VoiceResponse from 'twilio/lib/twiml/VoiceResponse' 5 | 6 | describe('inbound call controller', () => { 7 | jest.setTimeout(60000) 8 | 9 | // Test parameters 10 | const fromNumber = '+1001' 11 | const toNumber = '+1002' 12 | const twimlResponse = new VoiceResponse() 13 | const dial = twimlResponse.dial(); 14 | dial.conference('Room1234'); 15 | 16 | it('should generate twiml', async () => { 17 | const generateTwimlSpy = jest 18 | .spyOn(InboundCallService, 'generateTwiml') 19 | .mockResolvedValue(twimlResponse) 20 | 21 | const res = await request(app) 22 | .post('/inbound-call') 23 | .set('content-type', 'application/json') 24 | // .set('Authorization', process.env.AUTH_HEADER) 25 | .send({ 26 | From: fromNumber, 27 | Called: toNumber 28 | }) 29 | 30 | expect(res.status).toEqual(200) 31 | expect(res.text).toEqual('Room1234') 32 | expect(generateTwimlSpy).toBeCalledWith(fromNumber, toNumber) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /tests/controllers/session.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import * as SessionService from '../../src/services/session.service' 3 | import * as CreateConversation from '../../src/utils/createConversation.util' 4 | import * as DeleteConversation from '../../src/utils/deleteConversation.util' 5 | import { ConversationInstance } from 'twilio/lib/rest/conversations/v1/conversation' 6 | import { Mock } from 'moq.ts' 7 | import { app } from '../../src/app/app' 8 | 9 | describe('sessions controller', () => { 10 | jest.setTimeout(60000) 11 | 12 | // Test parameters 13 | const requestedPhoneNumbers = ['+1001', '+1002'] 14 | const activeProxyAddresses = { 15 | '+1001': ['+2001', '+2002'], 16 | '+1002': [] 17 | } 18 | const availableProxyAddresses = { 19 | '+1001': ['+2003', '+2004'], 20 | '+1002': ['+2001', '+2002', '+2003', '+2004'] 21 | } 22 | const conversationsSid = 'CHXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' 23 | 24 | // Mocks 25 | const getActiveProxyAddressesSpy = jest 26 | .spyOn(SessionService, 'getActiveProxyAddresses') 27 | .mockResolvedValue(activeProxyAddresses) 28 | 29 | const matchAvailableProxyAddressesSpy = jest 30 | .spyOn(SessionService, 'matchAvailableProxyAddresses') 31 | .mockResolvedValue(availableProxyAddresses) 32 | 33 | // Apparently with jest following is not possible, at least I couldn't find anything like this 34 | const mockedConversationInstance = new Mock() 35 | .setup(instance => instance.sid) 36 | .returns(conversationsSid) 37 | .object() 38 | 39 | const createConversationSpy = jest 40 | .spyOn(CreateConversation, 'createConversation') 41 | .mockResolvedValue(mockedConversationInstance) 42 | 43 | createConversationSpy.mockResolvedValue(mockedConversationInstance) 44 | 45 | // Tests 46 | it('should create a session', async () => { 47 | const addParticipantsToConversationSpy = jest 48 | .spyOn(SessionService, 'addParticipantsToConversation') 49 | .mockResolvedValue([]) 50 | 51 | const res = await request(app) 52 | .post('/sessions') 53 | .set('Content-Type', 'application/json') 54 | .set('Authorization', process.env.AUTH_HEADER) 55 | .send({ 56 | addresses: requestedPhoneNumbers 57 | }) 58 | 59 | expect(res.status).toEqual(200) 60 | expect(getActiveProxyAddressesSpy).toBeCalledWith(requestedPhoneNumbers) 61 | expect(matchAvailableProxyAddressesSpy).toBeCalledWith(activeProxyAddresses) 62 | expect(createConversationSpy).toBeCalledWith({ addresses: requestedPhoneNumbers }) 63 | expect(addParticipantsToConversationSpy).toBeCalledWith(conversationsSid, availableProxyAddresses) 64 | }) 65 | 66 | it('should delete conversation on failure and return 500', async () => { 67 | const addParticipantsToConversationSpy = jest 68 | .spyOn(SessionService, 'addParticipantsToConversation') 69 | .mockRejectedValue([]) 70 | 71 | const deleteConversationSpy = jest 72 | .spyOn(DeleteConversation, 'deleteConversation') 73 | .mockResolvedValue(true) 74 | 75 | const res = await request(app) 76 | .post('/sessions') 77 | .set('content-type', 'application/json') 78 | .set('Authorization', process.env.AUTH_HEADER) 79 | .send({ 80 | addresses: requestedPhoneNumbers 81 | }) 82 | 83 | expect(res.status).toEqual(500) 84 | expect(getActiveProxyAddressesSpy).toBeCalledWith(requestedPhoneNumbers) 85 | expect(matchAvailableProxyAddressesSpy).toBeCalledWith(activeProxyAddresses) 86 | expect(createConversationSpy).toBeCalledWith({ addresses: requestedPhoneNumbers }) 87 | expect(addParticipantsToConversationSpy).toBeCalledWith(conversationsSid, availableProxyAddresses) 88 | expect(deleteConversationSpy).toBeCalledWith(conversationsSid) 89 | }) 90 | }) 91 | -------------------------------------------------------------------------------- /tests/loadtest.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | const fetch = require('node-fetch') 3 | const twilio = require('twilio') 4 | 5 | const port = process.env.PORT || 3000 6 | 7 | const client = twilio( 8 | process.env.TWILIO_ACCOUNT_SID, 9 | process.env.TWILIO_AUTH_TOKEN 10 | ) 11 | 12 | const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) 13 | 14 | async function makeRequest (contacts) { 15 | const body = { addresses: contacts } 16 | 17 | return new Promise((resolve, reject) => { 18 | // eslint-disable-next-line new-cap 19 | const stringBuffer = new Buffer.from(`${process.env.AUTH_USERNAME}:${process.env.AUTH_PASSWORD}`, 'utf-8') 20 | const basicAuthToken = stringBuffer.toString('base64') 21 | 22 | fetch(`http://localhost:${port}/sessions`, { 23 | method: 'post', 24 | body: JSON.stringify(body), 25 | headers: { 26 | 'Content-Type': 'application/json', 27 | Authorization: `Basic ${basicAuthToken}` 28 | } 29 | }) 30 | .then(res => { 31 | const data = res.json() 32 | resolve(data) 33 | }) 34 | .catch(err => { 35 | console.log(err) 36 | reject(err) 37 | }) 38 | }) 39 | } 40 | 41 | async function runTest () { 42 | console.time('testCreate') 43 | 44 | // Create some sessions 45 | let results = [] 46 | try { 47 | const requests = [] 48 | for (let i = 0; i < 1000; ++i) { 49 | const contacts = ['+1925215' + ('0000' + i).slice(-4), '+1925635' + ('0000' + i).slice(-4)] 50 | console.log(contacts) 51 | 52 | const promise = makeRequest(contacts) 53 | requests.push(promise) 54 | await sleep(100) 55 | } 56 | 57 | results = await Promise.allSettled(requests) 58 | console.timeEnd('testCreate') 59 | } catch (e) { 60 | console.error(`failed ${JSON.stringify(e)}`) 61 | } finally { 62 | const deletePromises = [] 63 | 64 | for (let i = 0; i < results.length; ++i) { 65 | const response = results[i] 66 | 67 | console.log(response.value.sid) 68 | try { 69 | if (results[i].status === 'rejected') { 70 | // The conversation is deleted if something goes wrong 71 | // cleanupConversation(results[i].reason.sid); 72 | } else { 73 | // This is a test function, delete the conversation, we dont need it 74 | if (response.value.sid) { 75 | console.log(`Removing conversation ${response.value.sid}`) 76 | } 77 | const deletePromise = client.conversations.conversations(response.value.sid).remove() 78 | deletePromises.push(deletePromise) 79 | } 80 | } catch (e) { 81 | console.error(`Couldnt delete convo ${results[i]}: ${JSON.stringify(e)}`) 82 | } 83 | await sleep(50) 84 | } 85 | 86 | try { 87 | await Promise.all(deletePromises) 88 | } catch (err) { 89 | console.log(err) 90 | } 91 | 92 | console.log('Done cleaning up conversations') 93 | } 94 | } 95 | 96 | runTest() 97 | -------------------------------------------------------------------------------- /tests/services/geoRouter.test.ts: -------------------------------------------------------------------------------- 1 | import { geoRouter, getNearbyAreaCodeNumbers, isCanadianNumber } from '../../src/services/geoRouter.service' 2 | 3 | jest.mock('../../src/data/phoneNumberMap.json', () => ({ 4 | 44: [ 5 | '+447911121111' 6 | ], 7 | ca: { 8 | 236: [ 9 | '+12365550001' 10 | ], 11 | 587: [ 12 | '+15872222222' 13 | ], 14 | 639: [ 15 | '+16395550001', 16 | '+16395550002' 17 | ] 18 | }, 19 | us: { 20 | 212: [ 21 | '+12121111111' 22 | ], 23 | 312: [ 24 | '+13122222222' 25 | ], 26 | 408: [ 27 | '+14083333333', 28 | '+14084444444' 29 | ] 30 | } 31 | })) 32 | 33 | describe('phone matcher service', () => { 34 | describe('area code match service', () => { 35 | it('only includes US numbers first if a US area code is given', () => { 36 | const result = getNearbyAreaCodeNumbers('+12121112222') 37 | 38 | // eslint-disable-next-line quotes 39 | expect(result).toEqual(["+12121111111", "+13122222222", "+14083333333", "+14084444444"]) 40 | }) 41 | 42 | it('only includes canadian numbers if a CA area code is given', () => { 43 | const result = getNearbyAreaCodeNumbers('+12361112222') 44 | 45 | // eslint-disable-next-line quotes 46 | expect(result).toEqual(["+12365550001", "+15872222222", "+16395550001", "+16395550002"]) 47 | }) 48 | 49 | it('works if there are two participants', () => { 50 | const result1 = getNearbyAreaCodeNumbers('+12121112222') 51 | expect(result1).toEqual(['+12121111111', '+13122222222', '+14083333333', '+14084444444']) 52 | 53 | const result2 = getNearbyAreaCodeNumbers('+12121112223') 54 | expect(result2).toEqual(['+12121111111', '+13122222222', '+14083333333', '+14084444444']) 55 | }) 56 | }) 57 | 58 | describe('isCanadianNumber', () => { 59 | it('returns true if number is canadian', () => { 60 | const result = isCanadianNumber('+12365550001') 61 | expect(result).toBe(true) 62 | }) 63 | 64 | it('returns false if number is US', () => { 65 | const result = isCanadianNumber('+14155550001') 66 | expect(result).toBe(false) 67 | }) 68 | }) 69 | 70 | describe('geoRouter', () => { 71 | it('returns country-code matches for non-US/CA numbers', () => { 72 | const result = geoRouter('+447922233333') 73 | expect(result).toEqual(['+447911121111']) 74 | }) 75 | }) 76 | }) 77 | -------------------------------------------------------------------------------- /tests/services/inboundCall.test.ts: -------------------------------------------------------------------------------- 1 | 2 | import client from '../../src/twilioClient' 3 | import { generateTwiml } from '../../src/services/inboundCall.service' 4 | import { getConversationByAddressPair } from '../../src/utils/getConversationByAddressPair.util' 5 | import { listConversationParticipants } from '../../src/utils/listConversationParticipants.util' 6 | import { participantsToDial } from '../../src/utils/participantsToDial.util' 7 | import { generateConferenceName } from '../../src/utils/generateConferenceName.util' 8 | 9 | jest.mock('../../src/twilioClient') 10 | jest.mock('../../src/utils/getConversationByAddressPair.util') 11 | jest.mock('../../src/utils/listConversationParticipants.util') 12 | jest.mock('../../src/utils/participantsToDial.util') 13 | jest.mock('../../src/utils/generateConferenceName.util') 14 | 15 | describe('inbound call service', () => { 16 | const env = process.env 17 | const mockGetConversationAddressPair = jest.mocked(getConversationByAddressPair, true) 18 | const mockListConversationParticipants = jest.mocked(listConversationParticipants, true) 19 | const mockParticipantsToDial = jest.mocked(participantsToDial, true) 20 | 21 | beforeEach(() => { 22 | jest.resetModules() 23 | process.env = { ...env } 24 | }) 25 | 26 | afterEach(() => { 27 | process.env = env 28 | jest.resetAllMocks() 29 | }) 30 | 31 | it('returns out of session call announcement if no conversation if provided', async () => { 32 | mockGetConversationAddressPair.mockResolvedValue(undefined) 33 | 34 | process.env.CALL_ANNOUCEMENT_VOICE = 'alice' 35 | process.env.CALL_ANNOUCEMENT_LANGUAGE = 'en' 36 | process.env.OUT_OF_SESSION_MESSAGE_FOR_CALL = 'You session has ended, please call our main line.' 37 | const result = await generateTwiml('+1112223333', '+2223334444') 38 | 39 | expect(result.toString()).toBe('You session has ended, please call our main line.') 40 | }) 41 | 42 | it('creates a conference and dials participants if dialList is longer than 1', async () => { 43 | process.env.DOMAIN = 'testdomain.com' 44 | process.env.CALL_ANNOUNCMENT_VOICE = 'alice' 45 | process.env.CALL_ANNOUCEMENT_LANGUAGE = 'en' 46 | process.env.CONNECTING_CALL_ANNOUCEMENT = 'Connecting you to your agent now.' 47 | 48 | const mockedClient = jest.mocked(client, true) 49 | const createSpy = jest.fn((callObject) => { }) 50 | const mockedConferenceName = jest.mocked(generateConferenceName, true) 51 | 52 | mockedClient.calls = { 53 | create: (options) => createSpy(options) 54 | } as any 55 | 56 | mockGetConversationAddressPair.mockResolvedValue({ conversationSid: 'CH123' } as any) 57 | mockListConversationParticipants.mockResolvedValue([{ 58 | messagingBinding: { 59 | address: '+1112223333', 60 | proxyAddress: '+2223334444' 61 | } 62 | }] as any) 63 | 64 | const mockParticipantToDial = [{ 65 | address: '+1112223333', 66 | proxyAddress: '+2223334444' 67 | }, { 68 | address: '+3334445555', 69 | proxyAddress: '+4445556666' 70 | }] 71 | 72 | mockParticipantsToDial.mockReturnValueOnce(mockParticipantToDial) 73 | mockedConferenceName.mockReturnValue('test_conference') 74 | 75 | const result = await generateTwiml('+1112223333', '+2223334444') 76 | 77 | expect(result.toString()).toBe('Connecting you to your agent now.test_conference') 78 | 79 | expect(createSpy).toHaveBeenNthCalledWith(1, 80 | expect.objectContaining({ 81 | to: '+1112223333', 82 | from: '+2223334444', 83 | twiml: 'test_conference' 84 | }) 85 | ) 86 | 87 | expect(createSpy).toHaveBeenNthCalledWith(2, 88 | expect.objectContaining({ 89 | to: '+3334445555', 90 | from: '+4445556666', 91 | twiml: 'test_conference' 92 | }) 93 | ) 94 | }) 95 | 96 | it('throws an error if an outbound call fails', async () => { 97 | process.env.DOMAIN = 'testdomain.com' 98 | process.env.CALL_ANNOUNCMENT_VOICE = 'alice' 99 | process.env.CALL_ANNOUCEMENT_LANGUAGE = 'en' 100 | process.env.CONNECTING_CALL_ANNOUCEMENT = 'Connecting you to your agent now.' 101 | 102 | const mockedClient = jest.mocked(client, true) 103 | 104 | mockedClient.calls = { 105 | create: jest.fn(() => { throw new Error('Call fail') }) 106 | } as any 107 | 108 | mockGetConversationAddressPair.mockResolvedValue({ conversationSid: 'CH123' } as any) 109 | mockListConversationParticipants.mockResolvedValue([{ 110 | messagingBinding: { 111 | address: '+1112223333', 112 | proxyAddress: '+2223334444' 113 | } 114 | }] as any) 115 | 116 | const mockParticipantToDial = [{ 117 | address: '+1112223333', 118 | proxyAddress: '+2223334444' 119 | }, { 120 | address: '+3334445555', 121 | proxyAddress: '+4445556666' 122 | }] 123 | 124 | mockParticipantsToDial.mockReturnValueOnce(mockParticipantToDial) 125 | 126 | await expect(generateTwiml('+1112223333', '+2223334444')) 127 | .rejects 128 | .toThrowError('Call fail') 129 | }) 130 | 131 | it('connects caller with dial if there is only one other participant', async () => { 132 | process.env.CALL_ANNOUNCMENT_VOICE = 'alice' 133 | process.env.CALL_ANNOUCEMENT_LANGUAGE = 'en' 134 | process.env.CONNECTING_CALL_ANNOUCEMENT = 'Connecting you to your agent now.' 135 | 136 | mockGetConversationAddressPair.mockResolvedValue({ conversationSid: 'CH123' } as any) 137 | mockListConversationParticipants.mockResolvedValue([{ 138 | messagingBinding: { 139 | address: '+1112223333', 140 | proxyAddress: '+2223334444' 141 | } 142 | }] as any) 143 | 144 | const mockParticipantToDial = [{ 145 | address: '+1112223333', 146 | proxyAddress: '+2223334444' 147 | }] 148 | 149 | mockParticipantsToDial.mockReturnValueOnce(mockParticipantToDial) 150 | 151 | const result = await generateTwiml('+1112223333', '+2223334444') 152 | 153 | expect(result.toString()).toBe('Connecting you to your agent now.+1112223333') 154 | }) 155 | }) 156 | -------------------------------------------------------------------------------- /tests/services/session.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | addParticipantsToConversation, 3 | getActiveProxyAddresses, 4 | matchAvailableProxyAddresses, 5 | pullNumbers 6 | } from '../../src/services/session.service' 7 | import { listParticipantConversations } from '../../src/utils' 8 | import { retryAddParticipant } from '../../src/utils/retryAddParticipant.util' 9 | 10 | import { mockListParticipantsResponse } from '../support/testSupport' 11 | 12 | import { geoRouter } from '../../src/services/geoRouter.service' 13 | 14 | jest.mock('../../src/utils/listParticipantConversations.util') 15 | const mockedListParticipantConversations = jest.mocked(listParticipantConversations, true) 16 | 17 | jest.mock('../../src/utils/retryAddParticipant.util') 18 | const mockedRetryAddParticipant = jest.mocked(retryAddParticipant, true) 19 | 20 | jest.mock('../../src/services/geoRouter.service') 21 | const mockedGeoRouter = jest.mocked(geoRouter, true) 22 | 23 | describe('session service', () => { 24 | describe('getActiveProxyAdresses', () => { 25 | it('receives a list of numbers and returns a keyed object with proxy address arrays', async () => { 26 | mockedListParticipantConversations.mockResolvedValue(mockListParticipantsResponse as any) 27 | const result = await getActiveProxyAddresses(['+1112223333', '+2223334444']) 28 | 29 | expect(result).toEqual({ '+1112223333': ['+3334445555', '+3334449999', '+4445556666'], '+2223334444': ['+3334445555', '+3334449999', '+4445556666'] }) 30 | }) 31 | 32 | it('returns key with empty array if no active conversations', async () => { 33 | mockedListParticipantConversations.mockResolvedValue([] as any) 34 | const result = await getActiveProxyAddresses(['+1112223333', '+2223334444']) 35 | 36 | expect(result).toEqual({ '+1112223333': [], '+2223334444': [] }) 37 | }) 38 | 39 | it('throws error if listParticipantConversations throws', async () => { 40 | const mockError = new Error() 41 | mockedListParticipantConversations.mockResolvedValue(mockError as any) 42 | 43 | await expect(getActiveProxyAddresses(['+1112223333', '+2223334444'])).rejects.toThrow() 44 | }) 45 | }) 46 | 47 | describe('matchAvailableProxyAddresses', () => { 48 | const env = process.env 49 | 50 | beforeEach(() => { 51 | jest.resetModules() 52 | process.env = { ...env } 53 | }) 54 | 55 | afterEach(() => { 56 | process.env = env 57 | }) 58 | 59 | it('selects numbers that are not active proxy addresses', async () => { 60 | mockedGeoRouter.mockReturnValue(['+3334445555', '+4445556666', '+5556667777', '+6667778888']) 61 | 62 | const activeProxyAddresses = { '+1112223333': ['+3334445555', '+4445556666'], '+2223334444': ['+3334445555', '+4445556666'] } 63 | const result = await matchAvailableProxyAddresses(activeProxyAddresses) 64 | 65 | expect(result).toEqual({ '+1112223333': ['+5556667777', '+6667778888'], '+2223334444': ['+5556667777', '+6667778888'] }) 66 | expect(result).not.toContain('+4445555666') 67 | process.env = env 68 | }) 69 | 70 | it('throws Not enough numbers error if <1 number in pool', async () => { 71 | mockedGeoRouter.mockReturnValueOnce(['+3334445555', '+4445556666']) 72 | mockedGeoRouter.mockReturnValueOnce([]) 73 | const activeProxyAddresses = { '+1112223333': ['+3334445555', '+4445556666'] } 74 | 75 | await expect(matchAvailableProxyAddresses(activeProxyAddresses)).rejects.toThrowError('Not enough numbers available in pool for +1112223333') 76 | }) 77 | }) 78 | 79 | describe('addParticipantsToConversation', () => { 80 | it('calls retryParticipantAdd with conversation sid and participant addresses', async () => { 81 | mockedRetryAddParticipant.mockResolvedValue({} as any) 82 | const proxyBindings = { '+1112223333': ['+5556667777', '+6667778888'], '+2223334444': ['+5556667777', '+6667778888'] } 83 | 84 | addParticipantsToConversation('CHXXXXX', proxyBindings) 85 | 86 | expect(mockedRetryAddParticipant).toBeCalledWith('CHXXXXX', '+1112223333', ['+5556667777', '+6667778888']) 87 | expect(mockedRetryAddParticipant).toBeCalledWith('CHXXXXX', '+2223334444', ['+5556667777', '+6667778888']) 88 | expect(mockedRetryAddParticipant).toBeCalledTimes(2) 89 | }) 90 | 91 | it('throws error if retryAddParticipant throws', async () => { 92 | const mockError = new Error() 93 | mockedRetryAddParticipant.mockRejectedValue(mockError as any) 94 | const proxyBindings = { '+1112223333': ['+5556667777', '+6667778888'], '+2223334444': ['+5556667777', '+6667778888'] } 95 | 96 | await expect(addParticipantsToConversation('CHXXXXX', proxyBindings)).rejects.toThrow() 97 | }) 98 | }) 99 | 100 | describe('pullNumbers', () => { 101 | it('throws an error if geoRouter returns 0 phone numbers', () => { 102 | mockedGeoRouter.mockReturnValue([]) 103 | 104 | expect(() => { 105 | pullNumbers('+1112223333', ['+22233344444', '+3334445555'], 0) 106 | }).toThrowError() 107 | }) 108 | }) 109 | }) 110 | -------------------------------------------------------------------------------- /tests/support/testSupport.ts: -------------------------------------------------------------------------------- 1 | export const mockListParticipantsResponse = [{ 2 | accountSid: 'ACXXX', 3 | chatServiceSid: 'ISXXX', 4 | conversationState: 'active', 5 | conversationTimers: {}, 6 | conversationUniqueName: null, 7 | links: { 8 | conversation: 'https://conversations.twilio.com/v1/Conversations/CH82681a28cddf4475afda30a2d94d9b29', 9 | participant: 'https://conversations.twilio.com/v1/Conversations/CH82681a28cddf4475afda30a2d94d9b29/Participants/MB2d794fc4f4af4ee49ddcc1bdd8be6b94' 10 | }, 11 | participantIdentity: null, 12 | participantMessagingBinding: { 13 | name: null, 14 | level: null, 15 | type: 'sms', 16 | proxy_address: '+3334445555', 17 | address: '+2223334444', 18 | projected_address: null 19 | }, 20 | participantSid: 'MB2d794fc4f4af4ee49ddcc1bdd8be6b94', 21 | participantUserSid: null 22 | }, { 23 | accountSid: 'ACXXX', 24 | chatServiceSid: 'ISXXX', 25 | conversationState: 'inactive', 26 | conversationTimers: {}, 27 | conversationUniqueName: null, 28 | links: { 29 | conversation: 'https://conversations.twilio.com/v1/Conversations/CH82681a28cddf4475afda30a2d94d9b29', 30 | participant: 'https://conversations.twilio.com/v1/Conversations/CH82681a28cddf4475afda30a2d94d9b29/Participants/MB2d794fc4f4af4ee49ddcc1bdd8be6b94' 31 | }, 32 | participantIdentity: null, 33 | participantMessagingBinding: { 34 | name: null, 35 | level: null, 36 | type: 'sms', 37 | proxy_address: '+3334449999', 38 | address: '+2223334444', 39 | projected_address: null 40 | }, 41 | participantSid: 'MB2d794fc4f4af4ee49ddcc1bdd8be6b94', 42 | participantUserSid: null 43 | }, { 44 | accountSid: 'ACXXX', 45 | chatServiceSid: 'ISXXX', 46 | conversationState: 'closed', 47 | conversationTimers: {}, 48 | conversationUniqueName: null, 49 | links: { 50 | conversation: 'https://conversations.twilio.com/v1/Conversations/CH82681a28cddf4475afda30a2d94d9b29', 51 | participant: 'https://conversations.twilio.com/v1/Conversations/CH82681a28cddf4475afda30a2d94d9b29/Participants/MB2d794fc4f4af4ee49ddcc1bdd8be6b94' 52 | }, 53 | participantIdentity: null, 54 | participantMessagingBinding: { 55 | name: null, 56 | level: null, 57 | type: 'sms', 58 | proxy_address: '+4445555666', 59 | address: '+2223334444', 60 | projected_address: null 61 | }, 62 | participantSid: 'MB2d794fc4f4af4ee49ddcc1bdd8be6b94', 63 | participantUserSid: null 64 | }, { 65 | accountSid: 'ACXXX', 66 | chatServiceSid: 'ISXXX', 67 | conversationState: 'active', 68 | conversationTimers: {}, 69 | conversationUniqueName: null, 70 | links: { 71 | conversation: 'https://conversations.twilio.com/v1/Conversations/CH82681a28cddf4475afda30a2d94d9b29', 72 | participant: 'https://conversations.twilio.com/v1/Conversations/CH82681a28cddf4475afda30a2d94d9b29/Participants/MB2d794fc4f4af4ee49ddcc1bdd8be6b94' 73 | }, 74 | participantIdentity: null, 75 | participantMessagingBinding: { 76 | name: null, 77 | level: null, 78 | type: 'sms', 79 | proxy_address: '+4445556666', 80 | address: '+2223334444', 81 | projected_address: null 82 | }, 83 | participantSid: 'MB2d794fc4f4af4ee49ddcc1bdd8be6b94', 84 | participantUserSid: null 85 | }] 86 | 87 | export const mockParticpantInstance = { 88 | accountSid: 'ACXXX', 89 | chatServiceSid: 'ISXXX', 90 | conversationState: 'active', 91 | conversationTimers: {}, 92 | conversationUniqueName: null, 93 | links: { 94 | conversation: 'https://conversations.twilio.com/v1/Conversations/CH82681a28cddf4475afda30a2d94d9b29', 95 | participant: 'https://conversations.twilio.com/v1/Conversations/CH82681a28cddf4475afda30a2d94d9b29/Participants/MB2d794fc4f4af4ee49ddcc1bdd8be6b94' 96 | }, 97 | participantIdentity: null, 98 | participantMessagingBinding: { 99 | name: null, 100 | level: null, 101 | type: 'sms', 102 | proxy_address: '+3334445555', 103 | address: '+2223334444', 104 | projected_address: null 105 | }, 106 | participantSid: 'MB2d794fc4f4af4ee49ddcc1bdd8be6b94', 107 | participantUserSid: null 108 | } 109 | -------------------------------------------------------------------------------- /tests/utils/addParticipant.test.ts: -------------------------------------------------------------------------------- 1 | import { addParticipant } from '../../src/utils' 2 | import client from '../../src/twilioClient' 3 | 4 | import { ParticipantListInstanceCreateOptions } from 'twilio/lib/rest/conversations/v1/conversation/participant' 5 | 6 | jest.mock('../../src/twilioClient') 7 | const mockedClient = jest.mocked(client, true) 8 | 9 | const mockParticipant: Partial = { 10 | identity: '+1234' 11 | } 12 | 13 | describe('addParticipant util', () => { 14 | beforeEach(() => { 15 | jest.resetAllMocks() 16 | }) 17 | 18 | it('it adds participant to conversation', async () => { 19 | const createSpy = jest.fn((options) => { return mockParticipant }) 20 | const conversationsSpy = jest.fn((options) => { 21 | return { 22 | participants: { create: createSpy } 23 | } 24 | }) 25 | 26 | mockedClient.conversations = { 27 | conversations: conversationsSpy 28 | } as any 29 | 30 | const result = await addParticipant('myConversationSid', mockParticipant) 31 | expect(conversationsSpy).toBeCalledWith('myConversationSid') 32 | expect(createSpy).toBeCalledWith(mockParticipant) 33 | expect(result).not.toBeNull() 34 | }) 35 | 36 | it('calls quit if error is not a 429 retry', async () => { 37 | const createSpy = jest.fn((options) => { throw new Error('Twilio Problem') }) 38 | const conversationsSpy = jest.fn((options) => { 39 | return { 40 | participants: { create: createSpy } 41 | } 42 | }) 43 | 44 | mockedClient.conversations = { 45 | conversations: conversationsSpy 46 | } as any 47 | 48 | const consoleSpy = jest.spyOn(console, 'log') 49 | 50 | try { 51 | await addParticipant('myConversationSid', mockParticipant) 52 | } catch (e) { 53 | console.log(e) 54 | } 55 | expect(consoleSpy).toHaveBeenCalledWith('Quit without retry') 56 | }) 57 | 58 | it('throws error to retry on 429 status code', async () => { 59 | class TwilioError extends Error { 60 | status: number 61 | 62 | constructor (message) { 63 | super(message) 64 | this.name = 'ConcurrencyLimit' 65 | this.status = 429 66 | } 67 | } 68 | 69 | const createSpy = jest.fn((options) => { throw new TwilioError('Concurrency Limit') }) 70 | const conversationsSpy = jest.fn((options) => { 71 | return { 72 | participants: { create: createSpy } 73 | } 74 | }) 75 | 76 | mockedClient.conversations = { 77 | conversations: conversationsSpy 78 | } as any 79 | 80 | const consoleSpy = jest.spyOn(console, 'log') 81 | 82 | try { 83 | await addParticipant('myConversationSid', mockParticipant, { retries: 0, factor: 1, maxTimeout: 0, minTimeout: 0 }) 84 | } catch (e) { 85 | console.log(e) 86 | } 87 | 88 | expect(consoleSpy).toHaveBeenCalledWith('Re-trying on 429 error') 89 | }) 90 | }) 91 | -------------------------------------------------------------------------------- /tests/utils/createConversation.test.ts: -------------------------------------------------------------------------------- 1 | import { createConversation } from '../../src/utils' 2 | import client from '../../src/twilioClient' 3 | 4 | jest.mock('../../src/twilioClient') 5 | const mockedClient = jest.mocked(client, true) 6 | 7 | describe('createConversation util', () => { 8 | beforeEach(() => { 9 | jest.resetAllMocks() 10 | }) 11 | 12 | it('it creates conversation with options passed', async () => { 13 | const createSpy = jest.fn((options) => { return options }) 14 | mockedClient.conversations = { 15 | conversations: { 16 | create: (options) => createSpy(options) 17 | } 18 | } as any 19 | 20 | const result = await createConversation({ friendlyName: 'my conversation', addresses: ['1', '2'] }) 21 | 22 | expect(createSpy).toBeCalledWith({ friendlyName: 'my conversation', addresses: ['1', '2'] }) 23 | expect(result).toEqual({ friendlyName: 'my conversation', addresses: ['1', '2'] }) 24 | }) 25 | 26 | it('calls quit if error is not a 429 retry', async () => { 27 | mockedClient.conversations = { 28 | conversations: { 29 | create: (options) => { 30 | throw new Error('Twilio Problem') 31 | } 32 | } 33 | } as any 34 | 35 | const consoleSpy = jest.spyOn(console, 'log') 36 | 37 | try { 38 | await createConversation({ friendlyName: 'my conversation', addresses: ['1', '2'] }) 39 | } catch (e) { 40 | console.log(e) 41 | } 42 | 43 | expect(consoleSpy).toHaveBeenCalledWith('Quit without retry') 44 | }) 45 | 46 | it('throws error to retry on 429 status code', async () => { 47 | class TwilioError extends Error { 48 | status: number 49 | 50 | constructor (message) { 51 | super(message) 52 | this.name = 'ConcurrencyLimit' 53 | this.status = 429 54 | } 55 | } 56 | 57 | mockedClient.conversations = { 58 | conversations: { 59 | create: (options) => { 60 | throw new TwilioError('Too many requests') 61 | } 62 | } 63 | } as any 64 | 65 | const consoleSpy = jest.spyOn(console, 'log') 66 | 67 | try { 68 | await createConversation( 69 | { friendlyName: 'my conversation', addresses: ['1', '2'] }, 70 | { retries: 0, factor: 1, maxTimeout: 0, minTimeout: 0 }) 71 | } catch (e) { 72 | console.log(e) 73 | } 74 | 75 | expect(consoleSpy).toHaveBeenCalledWith('Re-trying on 429 error') 76 | }) 77 | }) 78 | -------------------------------------------------------------------------------- /tests/utils/deleteConversation.test.ts: -------------------------------------------------------------------------------- 1 | 2 | import client from '../../src/twilioClient' 3 | import { deleteConversation } from '../../src/utils' 4 | jest.mock('../../src/twilioClient') 5 | 6 | describe('deleteConversation util', () => { 7 | beforeEach(() => { 8 | jest.resetAllMocks() 9 | }) 10 | 11 | it('it deletes conversation', async () => { 12 | const mockedClient = jest.mocked(client, true) 13 | 14 | const removeSpy = jest.fn(() => { 15 | return true 16 | }) 17 | 18 | const conversationsSpy = jest.fn((options) => { 19 | return { 20 | remove: removeSpy 21 | } 22 | }) 23 | 24 | mockedClient.conversations = { 25 | conversations: conversationsSpy 26 | } as any 27 | 28 | const result = await deleteConversation('myConversationSid') 29 | expect(result).toBe(true) 30 | 31 | expect(conversationsSpy).toBeCalledWith('myConversationSid') 32 | expect(removeSpy).toBeCalled() 33 | }) 34 | 35 | it('throws an error if Twilio client throws', async () => { 36 | const mockedClient = jest.mocked(client, true) 37 | 38 | const removeSpy = jest.fn(() => { 39 | throw new Error('Twilio Problem') 40 | }) 41 | 42 | const conversationsSpy = jest.fn((options) => { 43 | return { 44 | remove: removeSpy 45 | } 46 | }) 47 | 48 | mockedClient.conversations = { 49 | conversations: conversationsSpy 50 | } as any 51 | 52 | const consoleSpy = jest.spyOn(console, 'log') 53 | 54 | try { 55 | await deleteConversation('myConversationSid') 56 | } catch (e) { 57 | console.log(e) 58 | } 59 | 60 | expect(consoleSpy).toHaveBeenCalledWith('Quit without retry') 61 | }) 62 | 63 | it('retrys if error is 429', async () => { 64 | class TwilioError extends Error { 65 | status: number 66 | 67 | constructor (message) { 68 | super(message) 69 | this.name = 'ConcurrencyLimit' 70 | this.status = 429 71 | } 72 | } 73 | 74 | const mockedClient = jest.mocked(client, true) 75 | 76 | const removeSpy = jest.fn(() => { 77 | throw new TwilioError('Too many connections') 78 | }) 79 | 80 | const conversationsSpy = jest.fn((options) => { 81 | return { 82 | remove: removeSpy 83 | } 84 | }) 85 | 86 | mockedClient.conversations = { 87 | conversations: conversationsSpy 88 | } as any 89 | 90 | const consoleSpy = jest.spyOn(console, 'log') 91 | 92 | try { 93 | await deleteConversation('myConversationSid', { retries: 0, factor: 1, maxTimeout: 0, minTimeout: 0 }) 94 | } catch (e) { 95 | console.log(e) 96 | } 97 | 98 | expect(consoleSpy).toHaveBeenCalledWith('Re-trying on 429 error') 99 | }) 100 | }) 101 | -------------------------------------------------------------------------------- /tests/utils/generateConferenceName.test.ts: -------------------------------------------------------------------------------- 1 | import { generateConferenceName } from '../../src/utils' 2 | 3 | describe('generateConferenceName', () => { 4 | it('provides a url-encoded unique string with a phone number and timestamp', () => { 5 | Date.now = jest.fn(() => 1234567890) 6 | 7 | const result = generateConferenceName('+1234567890') 8 | expect(result).toBe('+1234567890_at_1234567890') 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /tests/utils/getConversationByAddressPair.test.ts: -------------------------------------------------------------------------------- 1 | import { getConversationByAddressPair } from '../../src/utils' 2 | import client from '../../src/twilioClient' 3 | 4 | jest.mock('../../src/twilioClient') 5 | const mockedClient = jest.mocked(client, true) 6 | 7 | describe('GetConversationByAddressPair util', () => { 8 | beforeEach(() => { 9 | jest.resetAllMocks() 10 | }) 11 | 12 | it('it gets Conversation by address pair, excluding closed conversations', async () => { 13 | const mockParticipants = [{ 14 | conversationState: 'closed', 15 | conversationSid: 'myClosedConversationSid', 16 | participantMessagingBinding: { 17 | proxy_address: '+0000' 18 | } 19 | }, { 20 | conversationState: 'open', 21 | conversationSid: 'myClosedConversationSid', 22 | participantMessagingBinding: { 23 | proxy_address: '+0000' 24 | } 25 | }, { 26 | conversationState: 'open', 27 | conversationSid: 'myOpenConversationSid', 28 | participantMessagingBinding: { 29 | proxy_address: '+5678' 30 | } 31 | }] 32 | 33 | const listSpy = jest.fn((options) => { return mockParticipants }) 34 | mockedClient.conversations = { 35 | v1: { 36 | participantConversations: { 37 | list: (options) => listSpy(options) 38 | } 39 | } 40 | } as any 41 | 42 | const result = await getConversationByAddressPair('+1234', '+5678') 43 | expect(listSpy).toBeCalledWith({ address: '+1234' }) 44 | expect(result).toEqual({ conversationSid: 'myOpenConversationSid', conversationState: 'open', participantMessagingBinding: { proxy_address: '+5678' } }) 45 | // ToDo: add proper mocked response with ParticipantConversationInstance 46 | }) 47 | 48 | it('should throw error if Twilio client throws', async () => { 49 | const createSpy = jest.fn((options) => { throw new Error('Twilio Problem') }) 50 | mockedClient.conversations = { 51 | v1: { 52 | participantConversations: { 53 | list: (options) => createSpy(options) 54 | } 55 | } 56 | } as any 57 | 58 | await expect(getConversationByAddressPair('bad input', 'worse input')) 59 | .rejects 60 | .toThrowError('Error: Twilio Problem') 61 | }) 62 | 63 | it('should retry if Twilio error is 429', async () => { 64 | class TwilioError extends Error { 65 | status: number 66 | 67 | constructor (message) { 68 | super(message) 69 | this.name = 'ConcurrencyLimit' 70 | this.status = 429 71 | } 72 | } 73 | 74 | mockedClient.conversations = { 75 | v1: { 76 | participantConversations: { 77 | list: (options) => { 78 | throw new TwilioError('Too many requests') 79 | } 80 | } 81 | } 82 | } as any 83 | 84 | const consoleSpy = jest.spyOn(console, 'log') 85 | 86 | try { 87 | await getConversationByAddressPair('+1234', '+5678', { retries: 0, factor: 1, maxTimeout: 0, minTimeout: 0 }) 88 | } catch (e) { 89 | console.log(e) 90 | } 91 | 92 | expect(consoleSpy).toHaveBeenCalledWith('Re-trying on 429 error') 93 | }) 94 | }) 95 | -------------------------------------------------------------------------------- /tests/utils/listConversationParticipants.test.ts: -------------------------------------------------------------------------------- 1 | import { listConversationParticipants } from '../../src/utils' 2 | import client from '../../src/twilioClient' 3 | 4 | jest.mock('../../src/twilioClient') 5 | const mockedClient = jest.mocked(client, true) 6 | 7 | describe('listConversationParticipants', () => { 8 | it('it lists participants with provided conversation sid', async () => { 9 | const listSpy = jest.fn(() => { return ['fake_participant_1', 'fake_participant_2'] }) 10 | 11 | const conversationSpy = jest.fn((options) => { 12 | return { 13 | participants: { 14 | list: listSpy 15 | } 16 | } 17 | }) 18 | 19 | mockedClient.conversations = { 20 | conversations: conversationSpy 21 | } as any 22 | 23 | const result = await listConversationParticipants('CH123') 24 | 25 | expect(conversationSpy).toBeCalledWith('CH123') 26 | expect(listSpy).toBeCalled() 27 | expect(result).toEqual(['fake_participant_1', 'fake_participant_2']) 28 | }) 29 | 30 | it('should throw error if Twilio client throws', async () => { 31 | const listSpy = jest.fn(() => { throw new Error('Participant List Error') }) 32 | 33 | const conversationSpy = jest.fn((options) => { 34 | return { 35 | participants: { 36 | list: listSpy 37 | } 38 | } 39 | }) 40 | 41 | mockedClient.conversations = { 42 | conversations: conversationSpy 43 | } as any 44 | 45 | await expect(listConversationParticipants('CH123')) 46 | .rejects 47 | .toThrowError('Participant List Error') 48 | }) 49 | 50 | it('should retry if Twilio client throws a 429 error', async () => { 51 | class TwilioError extends Error { 52 | status: number 53 | 54 | constructor (message) { 55 | super(message) 56 | this.name = 'ConcurrencyLimit' 57 | this.status = 429 58 | } 59 | } 60 | 61 | const listSpy = jest.fn(() => { throw new TwilioError('Error to Retry') }) 62 | 63 | const conversationSpy = jest.fn((options) => { 64 | return { 65 | participants: { 66 | list: listSpy 67 | } 68 | } 69 | }) 70 | 71 | mockedClient.conversations = { 72 | conversations: conversationSpy 73 | } as any 74 | 75 | const consoleSpy = jest.spyOn(console, 'log') 76 | 77 | try { 78 | await listConversationParticipants('CH123', { retries: 0, factor: 1, maxTimeout: 0, minTimeout: 0 }) 79 | } catch (e) { 80 | console.log(e) 81 | } 82 | 83 | expect(consoleSpy).toBeCalledWith('Re-trying on 429 error') 84 | }) 85 | }) 86 | -------------------------------------------------------------------------------- /tests/utils/listParticipantConversations.test.ts: -------------------------------------------------------------------------------- 1 | import { listParticipantConversations } from '../../src/utils' 2 | import client from '../../src/twilioClient' 3 | 4 | jest.mock('../../src/twilioClient') 5 | const mockedClient = jest.mocked(client, true) 6 | 7 | describe('ListParticipantConversations util', () => { 8 | beforeEach(() => { 9 | jest.resetAllMocks() 10 | }) 11 | 12 | it('it lists conversations based on participant number', async () => { 13 | const createSpy = jest.fn((options) => { return options }) 14 | mockedClient.conversations = { 15 | participantConversations: { 16 | list: (options) => createSpy(options) 17 | } 18 | } as any 19 | 20 | const result = await listParticipantConversations('+1234') 21 | expect(createSpy).toBeCalledWith({ address: '+1234' }) 22 | expect(result).not.toBeNull() 23 | // ToDo: add proper mocked response with ParticipantConversationInstance 24 | }) 25 | 26 | it('calls quit if error is not a 429 retry', async () => { 27 | mockedClient.conversations = { 28 | participantConversations: { 29 | list: (options) => { 30 | throw new Error('Twilio Problem') 31 | } 32 | } 33 | } as any 34 | 35 | const consoleSpy = jest.spyOn(console, 'log') 36 | 37 | try { 38 | await listParticipantConversations('+1234') 39 | } catch (e) { 40 | console.log(e) 41 | } 42 | 43 | expect(consoleSpy).toHaveBeenCalledWith('Quit without retry') 44 | }) 45 | 46 | it('throws error to retry on 429 status code', async () => { 47 | class TwilioError extends Error { 48 | status: number 49 | 50 | constructor (message) { 51 | super(message) 52 | this.name = 'ConcurrencyLimit' 53 | this.status = 429 54 | } 55 | } 56 | 57 | mockedClient.conversations = { 58 | participantConversations: { 59 | list: (options) => { 60 | throw new TwilioError('Too many requests') 61 | } 62 | } 63 | } as any 64 | 65 | const consoleSpy = jest.spyOn(console, 'log') 66 | 67 | try { 68 | await listParticipantConversations( 69 | '+1234', 70 | { retries: 0, factor: 1, maxTimeout: 0, minTimeout: 0 }) 71 | } catch (e) { 72 | console.log(e) 73 | } 74 | 75 | expect(consoleSpy).toHaveBeenCalledWith('Re-trying on 429 error') 76 | }) 77 | }) 78 | -------------------------------------------------------------------------------- /tests/utils/participantsToDial.test.ts: -------------------------------------------------------------------------------- 1 | import { participantsToDial } from '../../src/utils' 2 | 3 | describe('participantsToDial', () => { 4 | it('only dials SMS type bindings that are not the person calling', async () => { 5 | const participants = [ 6 | { 7 | messagingBinding: { 8 | type: 'sms', 9 | address: '+1111111111', 10 | proxy_address: '+0000000000' 11 | } 12 | }, { 13 | messagingBinding: { 14 | type: 'chat', 15 | address: 'chat_client' 16 | } 17 | }, { 18 | messagingBinding: { 19 | type: 'sms', 20 | address: '+2222222222', 21 | proxy_address: '+0000000000' 22 | } 23 | } 24 | ] as any 25 | 26 | const result = participantsToDial(participants, '+1111111111') 27 | 28 | expect(result).toEqual([{ address: '+2222222222', proxyAddress: '+0000000000' }]) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /tests/utils/phoneNumberParser.test.ts: -------------------------------------------------------------------------------- 1 | import phoneNumberParser from '../../src/utils/phoneNumberParser' 2 | 3 | describe('phoneNumberParser util', () => { 4 | it('splits US phone number into components', () => { 5 | const result = phoneNumberParser('+14156501111') 6 | 7 | expect(result.country).toBe('1') 8 | expect(result.area).toBe('415') 9 | expect(result.prefix).toBe('650') 10 | expect(result.line).toBe('1111') 11 | }) 12 | 13 | it('successfully parses an international number', () => { 14 | const result = phoneNumberParser('+447911121111') 15 | 16 | expect(result.country).toBe('44') 17 | expect(result.area).toBe('791') 18 | expect(result.prefix).toBe('112') 19 | expect(result.line).toBe('1111') 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /tests/utils/retryAddParticipant.test.ts: -------------------------------------------------------------------------------- 1 | import { addParticipant } from '../../src/utils/addParticipant.util' 2 | import { mockParticpantInstance } from '../support/testSupport' 3 | import * as retry from '../../src/utils/retryAddParticipant.util' 4 | 5 | jest.mock('../../src/utils/addParticipant.util') 6 | 7 | describe('retryParticipantAdd', () => { 8 | it('throws if no proxy addresses are left', async () => { 9 | await expect(retry.retryAddParticipant('CH1234', '+111', [])) 10 | .rejects 11 | .toThrowError('No available proxy addresses for +111') 12 | }) 13 | 14 | it('calls itself again when 50416 is thrown to try another address', async () => { 15 | const mockAddParticipant = jest.mocked(addParticipant, true) 16 | 17 | class TwilioError extends Error { 18 | code: number 19 | 20 | constructor (message) { 21 | super(message) 22 | this.name = 'ConcurrencyLimit' 23 | this.code = 50416 24 | } 25 | } 26 | 27 | const retrySpy = jest.spyOn(retry, 'retryAddParticipant') 28 | 29 | const rejectedValue = new TwilioError('Number already in use') 30 | 31 | mockAddParticipant 32 | .mockRejectedValueOnce(rejectedValue) 33 | .mockResolvedValueOnce(mockParticpantInstance as any) 34 | 35 | const result = await retry.retryAddParticipant('CH1234', '+111', ['+222', '+333']) 36 | expect(retrySpy).toBeCalledTimes(1) 37 | expect(mockAddParticipant).nthCalledWith(1, 'CH1234', { 38 | 'messagingBinding.address': '+111', 39 | 'messagingBinding.proxyAddress': '+222' 40 | }) 41 | expect(mockAddParticipant).nthCalledWith(2, 'CH1234', { 42 | 'messagingBinding.address': '+111', 43 | 'messagingBinding.proxyAddress': '+333' 44 | }) 45 | expect(mockAddParticipant).toBeCalledTimes(2) 46 | expect(result).toEqual(mockParticpantInstance) 47 | }) 48 | 49 | it('throws an error if client err code is not 50416', async () => { 50 | const mockAddParticipant = jest.mocked(addParticipant, true) 51 | 52 | const rejectedValue = new Error('Twilio Problem') 53 | mockAddParticipant 54 | .mockRejectedValue(rejectedValue) 55 | 56 | await expect(retry.retryAddParticipant('CH1234', '+111', ['+222', '+333'])) 57 | .rejects 58 | .toThrow('Twilio Problem') 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "resolveJsonModule": true, 4 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 5 | 6 | /* Basic Options */ 7 | // "incremental": true, /* Enable incremental compilation */ 8 | "target": "esnext" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */, 9 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 10 | // "lib": [], /* Specify library files to be included in the compilation. */ 11 | // "allowJs": true, /* Allow javascript files to be compiled. */ 12 | // "checkJs": true, /* Report errors in .js files. */ 13 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ 14 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 15 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 16 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 17 | // "outFile": "./", /* Concatenate and emit output to single file. */ 18 | // "outDir": "./", /* Redirect output structure to the directory. */ 19 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 20 | // "composite": true, /* Enable project compilation */ 21 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 22 | // "removeComments": true, /* Do not emit comments to output. */ 23 | // "noEmit": true, /* Do not emit outputs. */ 24 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 25 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 26 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 27 | 28 | /* Strict Type-Checking Options */ 29 | "strict": false /* Enable all strict type-checking options. */, 30 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 31 | // "strictNullChecks": true, /* Enable strict null checks. */ 32 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 33 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 34 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 35 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 36 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 37 | 38 | /* Additional Checks */ 39 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 40 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 41 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 42 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 43 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 44 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an 'override' modifier. */ 45 | // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ 46 | 47 | /* Module Resolution Options */ 48 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 49 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 50 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 51 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 52 | // "typeRoots": [], /* List of folders to include type definitions from. */ 53 | // "types": [], /* Type declaration files to be included in compilation. */ 54 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 55 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 56 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 57 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 58 | 59 | /* Source Map Options */ 60 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 61 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 62 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 63 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 64 | 65 | /* Experimental Options */ 66 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 67 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 68 | 69 | /* Advanced Options */ 70 | "skipLibCheck": true /* Skip type checking of declaration files. */, 71 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 72 | } 73 | } 74 | --------------------------------------------------------------------------------