├── .env.sample ├── .gitignore ├── Procfile ├── README.md ├── app.js ├── app.json ├── bin └── www ├── common ├── authentication.js ├── sync.js ├── taskRouter.js └── tools.js ├── package.json ├── public └── stylesheets │ └── style.css ├── routes ├── callHandlerTwiml.js ├── callStatusCallbackHandler.js ├── index.js └── users.js ├── screenshots └── workflow-config.png ├── views ├── error.pug ├── index.pug └── layout.pug └── websockets ├── outboundDial └── eventManager.js └── realtimeStats └── eventManager.js /.env.sample: -------------------------------------------------------------------------------- 1 | TWILIO_ACCOUNT_SID=AC20... 2 | TWILIO_AUTH_TOKEN=3dd... 3 | TWILIO_OUTBOUND_WORKFLOW_SID=WW5... 4 | TWILIO_FLEX_WORKSPACE_SID=WSdd.. 5 | EXTERNAL_HOST=48bbdcac.ngrok.io 6 | DEBUG=outbound-dialing-backend:* -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | 24 | appConfig.js 25 | yarn.lock 26 | package-lock.json 27 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: npm start -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # twilio-flex-sample-backend 2 | 3 | This project is a demo backend service that supports various sample front end plugins to twilio flex 4 | 5 | # Services 6 | 7 | **To support [outbound dialing with conference](https://github.com/jhunter-twilio/plugin-flex-outbound-dialpad)** 8 | 9 | It exposes a secure websocket with authentication that can be used to: 10 | 11 | - trigger a call from a client 12 | - push updates of the call status back to that client 13 | - hang up a call for a given client 14 | 15 | It also exposes two end points that are called by twilio when: 16 | 17 | - providing updates to the status of calls generated by this server 18 | - twilio retrieves a twiml document to describe how to handle a call when it is answered 19 | 20 | **To support [realtime statistics dashboard for queues](https://github.com/jhunter-twilio/plugin-flex-realtime-stats-dashboard)** 21 | 22 | It exposes a secure websocket with authentication that can be used to: 23 | 24 | - recieve updates of stat changes 25 | - current queue stats updated every 5 seconds 26 | - todays overall stats updated every 30 seconds 27 | - turn stats on/off per channel 28 | - define your own SLA thresholds (max 3) 29 | 30 | # Dependencies 31 | 32 | Before setting up this server you must first created a dedicated TaskRouter workflow for outbound calls. You can do this [here](https://www.twilio.com/console/taskrouter/dashboard). Make sure it is part of your **Flex Task Assignment** workspace. 33 | 34 | - ensure there is the following matching workers expression for the only filter on the workspace 35 | - task.targetWorker==worker.contact_uri 36 | - ensure the priorty of the filter is set to 1000 (or at least the highest in the system) 37 | - make sure the filter matches to a queue with Everyone on it. The default Everyone queue will work but if you want to seperate real time reporting for outbound calls, you should make a dedicated queue for it with a queue expression 38 | - 1==1 39 | 40 | # Setup 41 | 42 | You can now setup the server, you can either deploy to heroku (which is free, you just need a login) or you can setup locally and expose via ngrok 43 | 44 | # Deploying to heroku 45 | 46 | 1. Use this link to begin [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/jhunter-twilio/twilio-flex-sample-backend/tree/master) 47 | 48 | 2. Populate the given variables when prompted 49 | 50 | - `TWILIO_OUTBOUND_WORKFLOW_SID` - the SID of the workflow you just created - used for creating tasks 51 | - `TWILIO_ACCOUNT_SID` - the account sid of your twilio account - used for calling Twilio APIs 52 | - `TWILIO_AUTH_TOKEN` - the auth token of your twilio account - used for calling Twilio APIs 53 | - `TWILIO_FLEX_WORKSPACE_SID` - TaskRouter Flex Assignment Workspace Sid, generated when creating a twilio flex project 54 | - `EXTERNAL_HOST` - the host that exposes this service - used for telling Twilio where to make callbacks when calling the Twilio APIs. Should be of the form .herokuapp.com 55 | 56 | 3. You're all set, the backend is ready. You can access it on https://.herokuapp.com 57 | 58 | # Deploying locally 59 | 60 | 1. Clone repository using `git clone` 61 | 2. run `npm install` 62 | 3. clone the .env.sample to .env 63 | 4. update .env as approproate, descriptions above 64 | 5. run `ngrok http 3000` 65 | 6. start server using `npm start` 66 | 67 | # change log 68 | 69 | v1.4 - added service that polls for real time stats ever 5 seconds and cumulative stats every 30 seconds, exposted via websocket 70 | 71 | v1.3 - added authentication provider for websocket using api token provided by twilio via flex 72 | 73 | v1.2 - migrated repository over to "twilio-flex-sample-backend" to be used with other plugins other than outbound dial, inroduced dotenv 74 | 75 | v1.1 - updated websocket endpoint to reflect its dedicated to outbound calling 76 | 77 | v1.0 - initial release 78 | 79 | ## Code of Conduct 80 | 81 | Please be aware that this project has a [Code of Conduct](https://github.com/twilio-labs/.github/blob/master/CODE_OF_CONDUCT.md). The tldr; is to just be excellent to each other ❤️ 82 | 83 | # TODOs 84 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | var createError = require("http-errors"); 2 | var express = require("express"); 3 | var path = require("path"); 4 | var cookieParser = require("cookie-parser"); 5 | var logger = require("morgan"); 6 | 7 | // add new routes 8 | var indexRouter = require("./routes/index"); 9 | var usersRouter = require("./routes/users"); 10 | var callStatusCallbackHandler = require("./routes/callStatusCallbackHandler"); 11 | var callHandlerTwiml = require("./routes/callHandlerTwiml"); 12 | 13 | // add websocket handlers 14 | var tools = require("./common/tools"); 15 | var authentication = require("./common/authentication"); 16 | var ws = require("ws"); 17 | 18 | // init twilio client 19 | const twilioClient = require("twilio")( 20 | process.env.TWILIO_ACCOUNT_SID, 21 | process.env.TWILIO_AUTH_TOKEN 22 | ); 23 | 24 | var app = express(); 25 | 26 | // view engine setup 27 | app.set("views", path.join(__dirname, "views")); 28 | app.set("view engine", "pug"); 29 | 30 | app.use(logger("dev")); 31 | app.use(express.json()); 32 | app.use(express.urlencoded({ extended: false })); 33 | app.use(cookieParser()); 34 | app.use(express.static(path.join(__dirname, "public"))); 35 | 36 | //setup authentication up for websocket 37 | app.use("/websocket", function(req, res, next) { 38 | var token = req.header("sec-websocket-protocol") 39 | ? req.header("sec-websocket-protocol") 40 | : req.header("token"); 41 | 42 | authentication 43 | .isValid(token) 44 | .then(() => { 45 | res.setHeader("Access-Control-Allow-Origin", "*"); 46 | res.setHeader("Access-Control-Allow-Method", "OPTIONS POST GET"); 47 | res.setHeader("Access-Control-Allow-Headers", "Content-Type"); 48 | next(); 49 | }) 50 | .catch(error => { 51 | res.status(403); 52 | res.send(createError(403)); 53 | }); 54 | }); 55 | 56 | // map traditional routes 57 | app.use("/", indexRouter); 58 | app.use("/users", usersRouter); 59 | app.use("/twilio-webhook/callStatusCallbackHandler", callStatusCallbackHandler); 60 | app.use("/twilio-webhook/callHandlerTwiml", callHandlerTwiml); 61 | 62 | /** 63 | * Outbound dialing websocket 64 | * 65 | */ 66 | 67 | // init websocket server dedicated to outbound dialing 68 | var outboundDialingWSS = new ws.Server({ noServer: true }); 69 | var outboundWSSHandler = require("./websockets/outboundDial/eventManager"); 70 | var callWebSocketMapping = new Map(); 71 | 72 | // setup message echo to originating client 73 | outboundDialingWSS.on("connection", webSocketClient => 74 | outboundWSSHandler.handleConnection( 75 | webSocketClient, 76 | twilioClient, 77 | callWebSocketMapping 78 | ) 79 | ); 80 | 81 | tools.setupHeartbeatMonitor("outboundDialingWSS", outboundDialingWSS, 30000); 82 | 83 | /** 84 | * Realtime stats websocket 85 | * 86 | */ 87 | 88 | var realtimeStatsWSS = new ws.Server({ noServer: true }); 89 | var realtimeStatsWSSHandler = require("./websockets/realtimeStats/eventManager"); 90 | 91 | // setup message echo to originating client 92 | realtimeStatsWSS.on("connection", webSocketClient => 93 | realtimeStatsWSSHandler.handleConnection(webSocketClient, twilioClient) 94 | ); 95 | 96 | tools.setupHeartbeatMonitor("realtimeStatsWSS", realtimeStatsWSS, 30000); 97 | tools.setupQueueStatsSchedule(realtimeStatsWSS, 2000, twilioClient); 98 | 99 | // store websocketServer so it can be referenced in http server 100 | app.set("outboundDialingWSS", outboundDialingWSS); 101 | app.set("realtimeStatsWSS", realtimeStatsWSS); 102 | 103 | //store these references so they can be access in routes 104 | app.set("callWebSocketMapping", callWebSocketMapping); 105 | app.set("twilioClient", twilioClient); 106 | 107 | console.info("AccountSid: " + process.env.TWILIO_ACCOUNT_SID); 108 | console.info( 109 | "Auth Token: " + process.env.TWILIO_AUTH_TOKEN.slice(0, 5) + "..." 110 | ); 111 | console.info( 112 | "Outbound Calling Workflow Sid: " + process.env.TWILIO_OUTBOUND_WORKFLOW_SID 113 | ); 114 | console.info("Backend: " + process.env.EXTERNAL_HOST); 115 | 116 | // catch 404 and forward to error handler 117 | app.use(function(req, res, next) { 118 | next(createError(404)); 119 | }); 120 | 121 | // error handler 122 | app.use(function(err, req, res, next) { 123 | // set locals, only providing error in development 124 | res.locals.message = err.message; 125 | res.locals.error = req.app.get("env") === "development" ? err : {}; 126 | 127 | // render the error page 128 | res.status(err.status || 500); 129 | res.render("error"); 130 | }); 131 | 132 | module.exports = { app, outboundDialingWSS, realtimeStatsWSS }; 133 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Twilio Flex Sample Backend", 3 | "description": "An application that serves a websocket connection to facilitate outbound call orchestration. The websocket allows for posting of updates directly back to the front end client that made the call.", 4 | "repository": "https://github.com/jhunter-twilio/outbound-dialing-backend.git", 5 | "logo": "https://node-js-sample.herokuapp.com/node.png", 6 | "keywords": ["node", "express", "twilio", "outbound", "dialing"], 7 | "addons": [], 8 | "buildpacks": [ 9 | { 10 | "url": "heroku/nodejs" 11 | } 12 | ], 13 | "env": { 14 | "TWILIO_ACCOUNT_SID": { 15 | "description": "Twilio Console Project Account Sid", 16 | "value": "CHANGEME", 17 | "required": true 18 | }, 19 | "TWILIO_AUTH_TOKEN": { 20 | "description": "Twilio Console Project Auth Token", 21 | "value": "CHANGEME", 22 | "required": true 23 | }, 24 | "TWILIO_OUTBOUND_WORKFLOW_SID": { 25 | "description": "TaskRouter workflow Sid dedicated to outbound calling", 26 | "value": "CHANGEME", 27 | "required": true 28 | }, 29 | "TWILIO_FLEX_WORKSPACE_SID": { 30 | "description": "TaskRouter Flex Assignment Workspace Sid, generated when creating a twilio flex project", 31 | "value": "CHANGEME", 32 | "required": true 33 | }, 34 | "EXTERNAL_HOST": { 35 | "description": "The hostname in the form \".herokuapp.com\" of this service. Use to tell Twilio where to send call status updates for each call made from this system.", 36 | "value": "CHANGEME", 37 | "required": true 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /bin/www: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Module dependencies. 5 | */ 6 | 7 | const dotenv = require("dotenv"); 8 | dotenv.config(); 9 | 10 | var App = require("../app"); 11 | var app = App.app; 12 | var outboundDialingWSS = App.outboundDialingWSS; 13 | var realtimeStatsWSS = App.realtimeStatsWSS; 14 | var debug = require("debug")("outbound-dialing-backend:server"); 15 | var http = require("http"); 16 | var url = require("url"); 17 | 18 | /** 19 | * Get port from environment and store in Express. 20 | */ 21 | 22 | var port = normalizePort(process.env.PORT || "3000"); 23 | app.set("port", port); 24 | 25 | /** 26 | * Create HTTP server. 27 | */ 28 | 29 | var server = http.createServer(app); 30 | 31 | /** 32 | * Listen on provided port, on all network interfaces. 33 | */ 34 | 35 | server.listen(port); 36 | server.on("error", onError); 37 | server.on("listening", onListening); 38 | 39 | //added for websocket management 40 | server.on("upgrade", function upgrade(request, socket, head) { 41 | const pathname = url.parse(request.url).pathname; 42 | 43 | if (pathname === "/websocket/outboundDial") { 44 | outboundDialingWSS.handleUpgrade(request, socket, head, function done(ws) { 45 | outboundDialingWSS.emit("connection", ws, request); 46 | console.debug("New outboundDial websocket created"); 47 | }); 48 | } else if (pathname === "/websocket/realtimeStats") { 49 | realtimeStatsWSS.handleUpgrade(request, socket, head, function done(ws) { 50 | realtimeStatsWSS.emit("connection", ws, request); 51 | console.debug("New realtimeStats websocket created"); 52 | }); 53 | } else { 54 | socket.destroy(); 55 | } 56 | }); 57 | 58 | /** 59 | * Normalize a port into a number, string, or false. 60 | */ 61 | 62 | function normalizePort(val) { 63 | var port = parseInt(val, 10); 64 | 65 | if (isNaN(port)) { 66 | // named pipe 67 | return val; 68 | } 69 | 70 | if (port >= 0) { 71 | // port number 72 | return port; 73 | } 74 | 75 | return false; 76 | } 77 | 78 | /** 79 | * Event listener for HTTP server "error" event. 80 | */ 81 | 82 | function onError(error) { 83 | if (error.syscall !== "listen") { 84 | throw error; 85 | } 86 | 87 | var bind = typeof port === "string" ? "Pipe " + port : "Port " + port; 88 | 89 | // handle specific listen errors with friendly messages 90 | switch (error.code) { 91 | case "EACCES": 92 | console.error(bind + " requires elevated privileges"); 93 | process.exit(1); 94 | break; 95 | case "EADDRINUSE": 96 | console.error(bind + " is already in use"); 97 | process.exit(1); 98 | break; 99 | default: 100 | throw error; 101 | } 102 | } 103 | 104 | /** 105 | * Event listener for HTTP server "listening" event. 106 | */ 107 | 108 | function onListening() { 109 | var addr = server.address(); 110 | var bind = typeof addr === "string" ? "pipe " + addr : "port " + addr.port; 111 | debug("Listening on " + bind); 112 | } 113 | -------------------------------------------------------------------------------- /common/authentication.js: -------------------------------------------------------------------------------- 1 | axios = require("axios"); 2 | 3 | function isValid(token) { 4 | return new Promise(function(resolve, reject) { 5 | var authOptions = { 6 | method: "POST", 7 | url: `https://${process.env.TWILIO_ACCOUNT_SID}:${ 8 | process.env.TWILIO_AUTH_TOKEN 9 | }@iam.twilio.com/v1/Accounts/${ 10 | process.env.TWILIO_ACCOUNT_SID 11 | }/Tokens/validate`, 12 | data: JSON.stringify({ token: token }), 13 | headers: { 14 | "Cache-Control": "no-cache", 15 | "Content-Type": "application/json" 16 | } 17 | }; 18 | 19 | axios(authOptions) 20 | .then(response => { 21 | if (response.data.valid) { 22 | resolve(); 23 | } else { 24 | console.log("unable to authenticate token"); 25 | reject(); 26 | } 27 | }) 28 | .catch(error => { 29 | console.log("ERROR: ", error); 30 | reject(); 31 | }); 32 | }); 33 | } 34 | 35 | module.exports = { isValid }; 36 | -------------------------------------------------------------------------------- /common/sync.js: -------------------------------------------------------------------------------- 1 | function deleteSyncMap(twilioClient, syncMapName) { 2 | return new Promise(function(resolve, reject) { 3 | const syncService = twilioClient.sync.services( 4 | process.env.TWILIO_FLEX_SYNC_SID 5 | ); 6 | 7 | syncService 8 | .syncMaps(syncMapName) 9 | .remove() 10 | .then(map => { 11 | console.log("Succesfully deleted map: " + syncMapName); 12 | resolve(true); 13 | }) 14 | .catch(error => { 15 | console.log("error deleting map: " + syncMapName); 16 | resolve(false); 17 | }); 18 | }); 19 | } 20 | 21 | function ensureSyncMapExists(twilioClient, mapName) { 22 | return new Promise(function(resolve, reject) { 23 | const syncService = twilioClient.sync.services( 24 | process.env.TWILIO_FLEX_SYNC_SID 25 | ); 26 | 27 | syncService 28 | .syncMaps(mapName) 29 | .fetch() 30 | .then(() => { 31 | console.log("sync map existence confirmed"), resolve(true); 32 | }) 33 | .catch(err => { 34 | console.log(err.message); 35 | console.log("creating sync map %s", MAP_NAME); 36 | syncService.syncMaps 37 | .create({ uniqueName: mapName }) 38 | .then(sync_map => { 39 | console.log("sync map created: " + sync_map.sid); 40 | resolve(true); 41 | }) 42 | .catch(err => { 43 | console.log(err.message); 44 | resolve(false); 45 | }); 46 | }); 47 | }); 48 | } 49 | 50 | function setSyncMapItem(twilioClient, mapName, itemId, data) { 51 | return new Promise(function(resolve, reject) { 52 | const syncService = twilioClient.sync.services( 53 | process.env.TWILIO_FLEX_SYNC_SID 54 | ); 55 | 56 | syncService 57 | .syncMaps(mapName) 58 | .syncMapItems(itemId) 59 | .update({ data: data }) 60 | .then(item => { 61 | console.log("Item updated: " + queueItem.sid); 62 | resolve(true); 63 | }) 64 | .catch(err => { 65 | console.log("retrying as create item"); 66 | 67 | //retry the item as a create 68 | syncService 69 | .syncMaps(mapName) 70 | .syncMapItems.create({ 71 | key: itemId, 72 | data: data 73 | }) 74 | .then(item => { 75 | console.log("Item created " + queueItem.sid); 76 | resolve(true); 77 | }) 78 | .catch(err => { 79 | console.log(err.message); 80 | resolve(false); 81 | }); 82 | }); 83 | }); 84 | } 85 | -------------------------------------------------------------------------------- /common/taskRouter.js: -------------------------------------------------------------------------------- 1 | var queueStats = []; 2 | 3 | function listQueues(twilioClient) { 4 | return new Promise(function(resolve, reject) { 5 | twilioClient.taskrouter 6 | .workspaces(process.env.TWILIO_FLEX_WORKSPACE_SID) 7 | .taskQueues.list() 8 | .then(result => { 9 | var queueArray = []; 10 | result.forEach(arrayItem => { 11 | queueArray.push({ 12 | sid: arrayItem.sid, 13 | friendlyName: arrayItem.friendlyName 14 | }); 15 | }); 16 | resolve({ success: true, queueArray: queueArray }); 17 | }) 18 | .catch(err => { 19 | console.log("err message: ", err.message); 20 | resolve({ success: false, message: err.message }); 21 | }); 22 | }); 23 | } 24 | 25 | function populateRealTimeStatsForQueueItem( 26 | twilioClient, 27 | queueItem, 28 | taskChannel 29 | ) { 30 | return new Promise(function(resolve, reject) { 31 | twilioClient.taskrouter 32 | .workspaces(process.env.TWILIO_FLEX_WORKSPACE_SID) 33 | .taskQueues(queueItem.sid) 34 | .realTimeStatistics() 35 | .fetch({ taskChannel: taskChannel ? taskChannel : undefined }) 36 | .then(result => { 37 | taskChannel = !taskChannel ? "all" : taskChannel; 38 | var realTimeStats = minimizeRealTimeStats(result); 39 | queueItem["realTimeStats_" + taskChannel] = realTimeStats; 40 | resolve(queueItem); 41 | }) 42 | .catch(err => { 43 | queueItem.realTimeStatsMessage = err.message; 44 | resolve(queueItem); 45 | }); 46 | }); 47 | } 48 | 49 | function populateCumulativeStatsForQueueItem( 50 | twilioClient, 51 | queueItem, 52 | taskChannel 53 | ) { 54 | var todaysDate = new Date(); 55 | todaysDate.setHours(0, 0, 0, 0); 56 | return new Promise(function(resolve, reject) { 57 | twilioClient.taskrouter 58 | .workspaces(process.env.TWILIO_FLEX_WORKSPACE_SID) 59 | .taskQueues(queueItem.sid) 60 | .cumulativeStatistics() 61 | .fetch({ 62 | taskChannel: taskChannel ? taskChannel : undefined, 63 | startDate: todaysDate, 64 | splitByWaitTime: "30,60,120" 65 | }) 66 | .then(result => { 67 | taskChannel = !taskChannel ? "all" : taskChannel; 68 | queueItem["cumulativeStats_" + taskChannel] = minimizeCumulativeStats( 69 | result 70 | ); 71 | resolve(queueItem); 72 | }) 73 | .catch(err => { 74 | queueItem.cumulativeStatsMessage = err.message; 75 | resolve(queueItem); 76 | }); 77 | }); 78 | } 79 | 80 | function minimizeRealTimeStats(realTimeStats) { 81 | if (realTimeStats) { 82 | var result = {}; 83 | result.activityStatistics = []; 84 | 85 | realTimeStats.activityStatistics.forEach(activity => { 86 | result.activityStatistics.push({ 87 | friendly_name: activity.friendly_name, 88 | workers: activity.workers 89 | }); 90 | }); 91 | 92 | result.oldestTask = realTimeStats.longestTaskWaitingAge; 93 | result.tasksByPriority = realTimeStats.tasksByPriority; 94 | result.tasksByStatus = realTimeStats.tasksByStatus; 95 | result.availableWorkers = realTimeStats.totalAvailableWorkers; 96 | result.eligibleWorkers = realTimeStats.totalEligibleWorkers; 97 | result.totalTasks = realTimeStats.totalTasks; 98 | 99 | return result; 100 | } else { 101 | return null; 102 | } 103 | } 104 | function minimizeCumulativeStats(cumulativeStatistics) { 105 | if (cumulativeStatistics) { 106 | var minimizedCumulativeStats = { 107 | rCreated: cumulativeStatistics.reservationsCreated, 108 | rRej: cumulativeStatistics.reservationsRejected, 109 | rAccepted: cumulativeStatistics.reservationsAccepted, 110 | rTimedOut: cumulativeStatistics.reservationsTimedOut, 111 | rCancel: cumulativeStatistics.reservationsCanceled, 112 | rRescind: cumulativeStatistics.reservationsRescinded, 113 | 114 | tCompl: cumulativeStatistics.tasksCompleted, 115 | tMoved: cumulativeStatistics.tasksMoved, 116 | tEnter: cumulativeStatistics.tasksEntered, 117 | tCanc: cumulativeStatistics.tasksCanceled, 118 | tDel: cumulativeStatistics.tasksDeleted, 119 | 120 | waitUntilCancel: cumulativeStatistics.waitDurationUntilCanceled, 121 | waitUntilAccept: cumulativeStatistics.waitDurationUntilAccepted, 122 | splitByWaitTime: cumulativeStatistics.splitByWaitTime, 123 | 124 | endTime: cumulativeStatistics.endTime, 125 | startTime: cumulativeStatistics.startTime, 126 | 127 | avgTaskAcceptanceTime: cumulativeStatistics.avgTaskAcceptanceTime 128 | }; 129 | 130 | return minimizedCumulativeStats; 131 | } else { 132 | return null; 133 | } 134 | } 135 | 136 | function fetchAllQueueStatistics(twilioClient, withCumulative) { 137 | // retrieves all queues for the environment configured workspace 138 | // then proceeds to fetch all stats data for them 139 | // returns an array of queue objects populated with the relevant stats nested on 140 | // the object 141 | return new Promise(function(resolve, reject) { 142 | console.log("Calling with cumulative: ", withCumulative); 143 | listQueues(twilioClient).then(result => { 144 | if (result.success) { 145 | var queueResultsArray = result.queueArray; 146 | var getStatsPromiseArray = []; 147 | queueResultsArray.forEach(queueItem => { 148 | // Every cycle retreive realtime stats for all known channels 149 | // comment out the channel if it is not used, 150 | // to save on redundent calls to backend 151 | getStatsPromiseArray.push( 152 | populateRealTimeStatsForQueueItem(twilioClient, queueItem, null) 153 | ); 154 | //get stats filtered by channel 155 | getStatsPromiseArray.push( 156 | populateRealTimeStatsForQueueItem(twilioClient, queueItem, "voice") 157 | ); 158 | getStatsPromiseArray.push( 159 | populateRealTimeStatsForQueueItem(twilioClient, queueItem, "chat") 160 | ); 161 | getStatsPromiseArray.push( 162 | populateRealTimeStatsForQueueItem(twilioClient, queueItem, "video") 163 | ); 164 | 165 | if (withCumulative) { 166 | getStatsPromiseArray.push( 167 | populateCumulativeStatsForQueueItem(twilioClient, queueItem, null) 168 | ); 169 | getStatsPromiseArray.push( 170 | populateCumulativeStatsForQueueItem( 171 | twilioClient, 172 | queueItem, 173 | "voice" 174 | ) 175 | ); 176 | getStatsPromiseArray.push( 177 | populateCumulativeStatsForQueueItem( 178 | twilioClient, 179 | queueItem, 180 | "chat" 181 | ) 182 | ); 183 | getStatsPromiseArray.push( 184 | populateCumulativeStatsForQueueItem( 185 | twilioClient, 186 | queueItem, 187 | "video" 188 | ) 189 | ); 190 | } 191 | }); 192 | 193 | Promise.all(getStatsPromiseArray).then(values => { 194 | // now merge the results from the backend to the 195 | // stats array currently maintained in memory 196 | queueResultsArray.forEach(queueResultItem => { 197 | let matched = false; 198 | queueStats.forEach(queueStatsItem => { 199 | if (queueStatsItem.sid === queueResultItem.sid) { 200 | Object.assign(queueStatsItem, queueResultItem); 201 | matched = true; 202 | } 203 | }); 204 | if (!matched) { 205 | // new queue 206 | queueStats.push(queueResultItem); 207 | } 208 | }); 209 | 210 | // remove, removed queues by ensuring the preserved queue stats array 211 | // queue sids appear in the results returned from the backend 212 | var tempArray = []; 213 | queueStats.forEach(queueStatsItem => { 214 | queueResultsArray.forEach(queueResultItem => { 215 | if (queueResultItem.sid === queueStatsItem.sid) { 216 | tempArray.push(queueStatsItem); 217 | } 218 | }); 219 | }); 220 | 221 | queueStats = tempArray; 222 | resolve(queueStats); 223 | }); 224 | } 225 | }); 226 | }); 227 | } 228 | 229 | function getCurrentQueueStats() { 230 | return queueStats; 231 | } 232 | 233 | module.exports = { fetchAllQueueStatistics, getCurrentQueueStats }; 234 | -------------------------------------------------------------------------------- /common/tools.js: -------------------------------------------------------------------------------- 1 | taskRouter = require("./taskRouter"); 2 | 3 | var startingIterations = 1; 4 | var maxIteractions = 1; 5 | var iterations = startingIterations; 6 | 7 | function setCORSHeaders(res) { 8 | res.setHeader("Access-Control-Allow-Origin", "*"); 9 | res.setHeader("Access-Control-Allow-Method", "OPTIONS POST GET"); 10 | res.setHeader("Access-Control-Allow-Headers", "Content-Type"); 11 | } 12 | 13 | function setupHeartbeatMonitor(name, websocketServer, timeout) { 14 | setInterval(function ping() { 15 | console.debug( 16 | name + " heartbeat, active clients: " + websocketServer.clients.size 17 | ); 18 | websocketServer.clients.forEach(function each(webSocketClient) { 19 | if (webSocketClient.isAlive === false) { 20 | console.warn( 21 | "Possible network issue: webSocketClient timed out after 30 seconds, terminating" 22 | ); 23 | 24 | return webSocketClient.terminate(); 25 | } 26 | 27 | webSocketClient.isAlive = false; 28 | webSocketClient.ping(() => {}); 29 | }); 30 | }, timeout); 31 | } 32 | 33 | function setupQueueStatsSchedule(websocketServer, timeout, twilioClient) { 34 | setInterval(function fetchQueueStats() { 35 | let startTime = new Date(); 36 | var withCumulative = iterations < maxIteractions ? false : true; 37 | iterations = 38 | iterations < maxIteractions ? ++iterations : startingIterations; 39 | taskRouter 40 | .fetchAllQueueStatistics(twilioClient, withCumulative) 41 | .then(queueStats => { 42 | console.log("Time taken to retrieve stats: ", new Date() - startTime); 43 | websocketServer.clients.forEach(function each(webSocketClient) { 44 | if (webSocketClient.readyState === webSocketClient.OPEN) { 45 | webSocketClient.send(JSON.stringify(queueStats)); 46 | } 47 | }); 48 | }); 49 | }, timeout); 50 | } 51 | 52 | module.exports = { 53 | setCORSHeaders, 54 | setupHeartbeatMonitor, 55 | setupQueueStatsSchedule 56 | }; 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "twilio-flex-sample-backend", 3 | "description": "Example backend service to orchestrate different enhancements to twilio flex", 4 | "version": "1.4.0", 5 | "author": "Jared Hunter ", 6 | "private": true, 7 | "scripts": { 8 | "start": "node ./bin/www" 9 | }, 10 | "dependencies": { 11 | "axios": "^0.18.0", 12 | "cookie-parser": "~1.4.4", 13 | "debug": "~2.6.9", 14 | "dotenv": "^8.0.0", 15 | "express": "~4.16.1", 16 | "http-errors": "~1.6.3", 17 | "morgan": "~1.9.1", 18 | "pug": "2.0.0-beta11", 19 | "twilio": "^3.31.0", 20 | "ws": "^7.0.0" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /public/stylesheets/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 50px; 3 | font: 14px "Lucida Grande", Helvetica, Arial, sans-serif; 4 | } 5 | 6 | a { 7 | color: #00B7FF; 8 | } 9 | -------------------------------------------------------------------------------- /routes/callHandlerTwiml.js: -------------------------------------------------------------------------------- 1 | var tools = require("../common/tools"); 2 | var express = require("express"); 3 | var VoiceResponse = require("twilio").twiml.VoiceResponse; 4 | var router = express.Router(); 5 | 6 | router.options("/", (req, res, next) => { 7 | res.send(); 8 | }); 9 | 10 | router.post("/", (req, res, next) => { 11 | res.setHeader("Content-Type", "application/xml"); 12 | 13 | console.debug("\tcallhandler for: ", req.body.CallSid); 14 | console.debug("\t\tworker:\t", req.query.workerContactUri); 15 | console.debug("\t\tto:\t", req.body.To); 16 | console.debug("\t\tworkflowSid:\t", process.env.TWILIO_OUTBOUND_WORKFLOW_SID); 17 | 18 | var taskAttributes = { 19 | targetWorker: req.query.workerContactUri, 20 | autoAnswer: "true", 21 | type: "outbound", 22 | direction: "outbound", 23 | name: req.body.To 24 | }; 25 | 26 | let twiml = new VoiceResponse(); 27 | 28 | var enqueue = twiml.enqueue({ 29 | workflowSid: `${process.env.TWILIO_OUTBOUND_WORKFLOW_SID}` 30 | }); 31 | 32 | enqueue.task(JSON.stringify(taskAttributes)); 33 | res.send(twiml.toString()); 34 | }); 35 | 36 | module.exports = router; 37 | -------------------------------------------------------------------------------- /routes/callStatusCallbackHandler.js: -------------------------------------------------------------------------------- 1 | var tools = require("../common/tools"); 2 | var express = require("express"); 3 | var router = express.Router(); 4 | 5 | router.options("/", (req, res, next) => { 6 | res.send(); 7 | }); 8 | 9 | router.post("/", (req, res, next) => { 10 | // callback receive send 200 immediately 11 | res.send(); 12 | 13 | console.debug("\tcallback for: ", req.body.CallSid); 14 | console.debug("\t\tto:\t", req.body.To); 15 | console.debug("\t\tfrom:\t", req.body.From); 16 | console.debug("\t\tstatus:\t", req.body.CallStatus); 17 | 18 | var callWebSocketMapping = req.app.get("callWebSocketMapping"); 19 | var inboundClient = callWebSocketMapping.get(req.body.CallSid); 20 | 21 | var response = JSON.stringify({ 22 | messageType: "callUpdate", 23 | callSid: req.body.CallSid, 24 | callStatus: req.body.CallStatus 25 | }); 26 | 27 | if (inboundClient && inboundClient.readyState === inboundClient.OPEN) { 28 | inboundClient.send(response); 29 | } else { 30 | console.error( 31 | "couldnt find open websocket client for callsid: " + req.body.CallSid 32 | ); 33 | } 34 | 35 | if (req.body.CallStatus === "completed") { 36 | callWebSocketMapping.delete(req.body.CallSid); 37 | console.debug( 38 | "\tremoved map entry for completed call: " + req.body.CallSid 39 | ); 40 | } 41 | }); 42 | 43 | module.exports = router; 44 | -------------------------------------------------------------------------------- /routes/index.js: -------------------------------------------------------------------------------- 1 | var express = require("express"); 2 | var router = express.Router(); 3 | 4 | /* GET home page. */ 5 | router.get("/", function(req, res, next) { 6 | res.render("index", { title: "Express" }); 7 | }); 8 | 9 | module.exports = router; 10 | -------------------------------------------------------------------------------- /routes/users.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var router = express.Router(); 3 | 4 | /* GET users listing. */ 5 | router.get('/', function(req, res, next) { 6 | res.send('respond with a resource'); 7 | }); 8 | 9 | module.exports = router; 10 | -------------------------------------------------------------------------------- /screenshots/workflow-config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio-labs/twilio-flex-sample-backend/c8ba2213e4c6cd82c55aea46bf62f9d8b4600242/screenshots/workflow-config.png -------------------------------------------------------------------------------- /views/error.pug: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | h1= message 5 | h2= error.status 6 | pre #{error.stack} 7 | -------------------------------------------------------------------------------- /views/index.pug: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | h1= title 5 | p Welcome to #{title} 6 | -------------------------------------------------------------------------------- /views/layout.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | title= title 5 | link(rel='stylesheet', href='/stylesheets/style.css') 6 | body 7 | block content 8 | -------------------------------------------------------------------------------- /websockets/outboundDial/eventManager.js: -------------------------------------------------------------------------------- 1 | function handleConnection(webSocketClient, twilioClient, callWebSocketMapping) { 2 | // setup ping response 3 | webSocketClient.isAlive = true; 4 | webSocketClient.on("pong", () => { 5 | webSocketClient.isAlive = true; 6 | }); 7 | 8 | // Create handler for events coming in on this socket connection 9 | webSocketClient.on("message", data => 10 | handleIncomingMessage( 11 | data, 12 | webSocketClient, 13 | twilioClient, 14 | callWebSocketMapping 15 | ) 16 | ); 17 | } 18 | 19 | function handleIncomingMessage( 20 | data, 21 | webSocketClient, 22 | twilioClient, 23 | callWebSocketMapping 24 | ) { 25 | // look for JSON object, if it fails to parse 26 | // echo the message back 27 | 28 | var socketResponse; 29 | 30 | try { 31 | data = JSON.parse(data); 32 | 33 | if (data.method === "call") { 34 | makeOutboundCall(twilioClient, data).then(resp => { 35 | // if outbound call succesfully placed 36 | if (resp.success) { 37 | var call = resp.call; 38 | 39 | // map the callSid to the calling client so we know who to update when status events come back from twilio 40 | callWebSocketMapping.set(call.sid, webSocketClient); 41 | 42 | // let client know their call is queued 43 | socketResponse = JSON.stringify({ 44 | messageType: "callUpdate", 45 | callSid: call.sid, 46 | callStatus: call.status.toString() 47 | }); 48 | } else { 49 | // relay error message to client 50 | socketResponse = JSON.stringify({ 51 | messageType: "error", 52 | message: resp.error.message 53 | }); 54 | } 55 | webSocketClient.send(socketResponse); 56 | }); 57 | } else if (data.method === "hangup" && data.callSid) { 58 | hangupCall(twilioClient, data).then(resp => { 59 | // if success we let the call status update event update the client 60 | // if we fail we send the failure message back to the client 61 | if (!resp.success) { 62 | socketResponse = JSON.stringify({ 63 | messageType: "error", 64 | message: resp.error.message 65 | }); 66 | webSocketClient.send(socketResponse); 67 | } 68 | }); 69 | } else { 70 | // if it was a JSON object and we dont recognize it, let the client know 71 | socketResponse = "Unrecognized payload: " + data; 72 | webSocketClient.send(socketResponse); 73 | console.warn(socketResponse); 74 | } 75 | } catch (e) { 76 | // if not an object, echo back to originating client 77 | socketResponse = "echo: " + data; 78 | webSocketClient.send(socketResponse); 79 | } 80 | } 81 | 82 | function makeOutboundCall(twilioClient, data) { 83 | return new Promise(function(resolve, reject) { 84 | var callHandlerCallbackURL = encodeURI( 85 | "https://" + 86 | process.env.EXTERNAL_HOST + 87 | "/twilio-webhook/callHandlerTwiml?workerContactUri=" + 88 | data.workerContactUri 89 | ); 90 | 91 | var statusCallbackURL = 92 | "https://" + 93 | process.env.EXTERNAL_HOST + 94 | "/twilio-webhook/callStatusCallbackHandler"; 95 | 96 | twilioClient.calls 97 | .create({ 98 | url: callHandlerCallbackURL, 99 | to: data.to, 100 | from: data.from, 101 | statusCallback: statusCallbackURL, 102 | statusCallbackEvent: ["ringing", "answered", "completed"] 103 | }) 104 | .then(call => { 105 | logCall(call); 106 | resolve({ success: true, call: call }); 107 | }) 108 | .catch(error => { 109 | console.error("\tcall creation failed"); 110 | console.error("\tERROR: ", error.message); 111 | resolve({ success: false, error: error }); 112 | }); 113 | }); 114 | } 115 | 116 | function hangupCall(twilioClient, data) { 117 | return new Promise(function(resolve, reject) { 118 | twilioClient 119 | .calls(data.callSid) 120 | .update({ status: "completed" }) 121 | .then(call => { 122 | logCall(call); 123 | resolve({ success: true, call: call }); 124 | }) 125 | .catch(error => { 126 | console.error("\tcall failed to terminate: ", data.callSid); 127 | console.error("\tERROR: ", error); 128 | 129 | resolve({ success: false, error: error }); 130 | }); 131 | }); 132 | } 133 | 134 | function logCall(call) { 135 | console.debug("\tcall: ", call.sid); 136 | console.debug("\t\tto:\t", call.to); 137 | console.debug("\t\tfrom:\t", call.from); 138 | console.debug("\t\tstatus:\t", call.status.toString()); 139 | } 140 | 141 | module.exports = { handleConnection }; 142 | -------------------------------------------------------------------------------- /websockets/realtimeStats/eventManager.js: -------------------------------------------------------------------------------- 1 | taskRouter = require("../../common/taskRouter"); 2 | 3 | function handleConnection(webSocketClient, twilioClient) { 4 | // setup ping response 5 | webSocketClient.isAlive = true; 6 | webSocketClient.on("pong", () => { 7 | webSocketClient.isAlive = true; 8 | }); 9 | 10 | webSocketClient.send(JSON.stringify(taskRouter.getCurrentQueueStats())); 11 | 12 | // Create handler for events coming in on this socket connection 13 | webSocketClient.on("message", data => 14 | handleIncomingMessage(data, webSocketClient) 15 | ); 16 | } 17 | 18 | function handleIncomingMessage(data, webSocketClient) { 19 | // if not an object, echo back to originating client 20 | var socketResponse = "echo: " + data; 21 | webSocketClient.send(socketResponse); 22 | } 23 | 24 | module.exports = { handleConnection }; 25 | --------------------------------------------------------------------------------