© The Author(s), under exclusive license to APress Media, LLC, part of Springer Nature 2021
R. StringerReal-Time Twilio and Flybasehttps://doi.org/10.1007/978-1-4842-7074-5_5

5. Building a Salesforce-Powered Call Center

Roger Stringer1  
(1)
Penticton, BC, Canada
 

In the world of telephony, an automated call distribution (ACD) system is a system that distributes incoming calls to a specific group of agents based on the customer’s selection, customer’s telephone number, selected incoming line to the system, or time of day the call was processed. We also call this a call center.

A couple years ago, Twilio’s Charles Oppenheimer (https://github.com/choppen5) built a demo of a Salesforce-embeddable ACD (https://github.com/choppen5/client-acd) using Twilio Client and Ruby. Much credit to Charles in this regard.

We have simply taken Charles’ demo and converted it to Node.js with a Flybase-powered backend to handle the distribution of calls, rather than the original Ruby/Mongo system. The result is a cleaner call center that’s easy to modify and integrate into other CRMs.

Necessary Tools

  • Flybase.io (https://flybase.io/) as our backend, handling storing data, passing events, and our call queues.

  • Twilio Client (www.twilio.com/webrtc), a WebRTC interface to Twilio. In our demo, we are using the JavaScript library that gives us an API and connection to Twilio to receive the call within our Salesforce browser delivering the call via WebRTC. Twilio Client also gives us the ability to control the call via our softphone.

  • Heroku will be used as our web host, but you can host your call center anywhere you’d like.

  • Salesforce Open CTI (https://developer.salesforce.com/page/Open_CTI) is an open API to allow third-party CTI vendors to connect telephony channels into the Salesforce CRM interface. In our demo, we use Open CTI to house our softphone and drive the click-to-dial/text functionality. The demo requires no plugins or installed software, thanks to the design of Open CTI. 

The actual Salesforce integration is optional, and you can easily insert your softphone into another CRM. Part 2 of this tutorial will actually use Flybase to build a simple CRM with the softphone included as a widget.

Getting Started

You can find the full source code here: https://github.com/flybaseio/callcenter.

First, let’s set up our Node.js app.

Create “package.json”:
{
  "name": "callcenter",
  "version": "0.0.1",
  "description": "Client ACD powered by Flybase, Twilio and Node.js",
  "main": "app.js",
  "repository": "https://github.com/flybaseio/callcenter",
  "scripts": {
    "test": "echo "Error: no test specified" && exit 1"
  },
  "keywords": [
    "twilio",
    "data mcfly",
    "flybase",
    "twilio",
    "sms"
  ],
  "author": "Roger Stringer",
  "license": "MIT",
  "dependencies": {
    "body-parser": "~1.4.2",
    "ejs": "~0.8.5",
    "express": "~3.4.8",
    "flybase": "1.7.2",
    "less-middleware": "~0.2.1-beta",
    "method-override": "~2.0.2",
    "moment": "~2.5.1",
    "node-buzz": "~1.1.0",
    "twilio": "~1.6.0"
  },
  "engines": {
    "node": "0.12"
  }
}
This will tell our call center what modules we want to install for our Node app. Now, we want to create our “app.js” file to handle all our backend work:
var express = require('express');
var bodyParser = require('body-parser');
var methodOverride = require('method-override');
var path = require('path');
var config = require( path.join(__dirname, 'app', 'config') );
var app = express();
app.set('views', path.join(__dirname, 'app', 'views'));
app.engine('html', require('ejs').renderFile);
app.set('view engine', 'html');
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({     extended: true     }));
app.use(express.static( path.join(__dirname, 'app', 'public')));
var port = process.env.PORT || 5000; // set our port
var twilio = require('twilio');
var client = twilio(config.twilio.sid, config.twilio.token);
var flybase = require('flybase');
var callsRef = flybase.init(config.flybase.app_name, "calls", config.flybase.api_key);
var agentsRef = flybase.init(config.flybase.app_name, "agents", config.flybase.api_key);
var queueId = '';
var good2go = false;
// backend routes
client.queues.list(function(err, data) {
     var to_go = data.queues.length;
     data.queues.forEach(function(queue) {
          if( queue.friendlyName === config.twilio.queueName ){
               queueId = queue.sid;
               console.log( "Queueid = #" + queueId + " for #" +  config.twilio.queueName );
               good2go = true;
          }
          to_go--;
          if( to_go == 0 ){
               if( queueId === '' ){
                    client.queues.create({
                         friendlyName: config.twilio.queueName
                    }, function(err, queue) {
                         queueId = queue.sid;
                    });
               }
          }
     });
});
// listen for events via Flybase...
// if an agent gets disconnected then we log them off...
agentsRef.on('agent-removed', function (data) {
     var data = JSON.parse( data );
     console.log( data.username + " has left the building");
     update_agent(data.username,{
          status: 'LoggedOut'
     });
});
// return number of agents with status set to Ready
agentsRef.on('get-ready-agents', function (data) {
     var adNag = function() {
          agentsRef.where({"status": 'Ready'}).on('value',function( rec ){
               console.log( rec.count() + ' agents are Ready' );
               if( rec.count() ){
                    agentsRef.trigger('agents-ready', rec.count() );
               }else{
                    agentsRef.trigger('agents-ready', "0" );
               }
          });
     };
     setTimeout(adNag, 1500);
});
//     listen for outgoing calls
app.post('/dial', function (req, res) {
     var phoneNumber = req.param('PhoneNumber');
     var dial_id = config.twilio.fromNumber;
     if( typeof req.param('CallerID') !== 'undefined' ){
          var dial_id = req.param('CallerID');
     }
     var twiml = new twilio.TwimlResponse();
     twiml.dial(phoneNumber, {
          callerId:dial_id
     });
     console.log("Response text for /dial post = #", twiml.toString());
     res.writeHead(200, {
          'Content-Type':'text/xml'
     });
     res.end( twiml.toString() );
});
//     listen for incoming calls
app.post('/voice', function (req, res) {
     var queuename = config.twilio.queueName;
     var sid = req.param('CallSid');
     var callerId = req.param('Caller');
     var addToQ = 0;
     var dialQueue = '';
     var client_name = '';
     //     searches for an agent who has been set to Ready for the longest time and connects them to the caller...
     getLongestIdle(true, function( bestClient ){
          if( bestClient ){
               console.log("Routing incoming voice call to best agent = #", bestClient);
               var client_name = bestClient;
          }else{
               console.log( 'no agent was found, adding caller to #', config.twilio.queueName );
               var dialQueue = queuename;
               addToQ = 1;
          }
          var twiml = new twilio.TwimlResponse();
          if( addToQ ){
               twiml.say("Please wait for the next available agent",{
                    voice:'woman'
               }).enqueue(config.twilio.queueName);
          }else{
               twiml.dial({
                    'timeout':'10',
                    'action':'/handledialcallstatus',
                    'callerId':callerid
               }, function(node) {
                    this.client( client_name );
               });
               update_call(sid, {
                    'sid': sid,
                    'agent': client_name,
                    'status': 'ringing'
               });
          }
          console.log("Response text for /voice post = #", twiml.toString());
          res.writeHead(200, {
               'Content-Type':'text/xml'
          });
          res.end( twiml.toString() );
     });
});
app.post('/handledialcallstatus', function (req, res) {
     var sid = req.param('CallSid');
     var twiml = new twilio.TwimlResponse();
     if( req.param('DialCallStatus') == 'no-answer' ){
          callsRef.where({"sid": sid}).on('value',function( rec ){
               if( rec.count() !== null ){
                    var sidinfo = rec.first().value();
                    if( sidinfo ){
                         var agent = sidinfo.agent;
                         update_agent(agent, {
                              'status': 'missed'
                         });
                    }
                    // Change agent status for agents that missed calls
               }
               //     redirect and try to get a new agent...
               twiml.redirect('/voice');
          });
     }else{
          twiml.hangup();
     }
     console.log("Response text for /handledialcallstatus post = #", twiml.toString());
     res.writeHead(200, {
          'Content-Type':'text/xml'
     });
     res.end( twiml.toString() );
});
// assign a twilio call token to the agent
app.get('/token', function(req, res) {
     var client_name = "anonymous";
     if( typeof req.param("client") !== "undefined" ){
          client_name = req.param("client");
     }
     var capability = new twilio.Capability( config.twilio.sid, config.twilio.token );
     capability.allowClientIncoming( client_name );
     capability.allowClientOutgoing( config.twilio.appid );
    var token = capability.generate();
    res.end(token);
});
// return flybase info to the softphone...
app.get('/getconfig', function(req, res) {
     res.json({
          app_name: config.flybase.app_name,
          api_key: config.flybase.api_key
     });
});
// return a phone number
app.get('/getCallerId', function(req, res) {
     var client_name = "anonymous";
     if( typeof req.param("from") !== "undefined" ){
          client_name = req.param("from");
     }
     res.end( config.twilio.fromNumber );
});
app.post('/track', function(req, res) {
});
app.get('/', function(req, res) {
     var client_name = "anonymous";
     if( typeof req.param("client") !== "undefined" ){
          client_name = req.param("client");
     }
     res.render('index', {
          client_name: client_name,
          anyCallerId: 'none'
     });
});
var server = app.listen(port, function() {
     console.log('Listening on port %d', server.address().port);
});
// various functions ==========================================
//     find the caller who's been `Ready` the longest
function getLongestIdle( callRouting, callback ){
     if( callRouting ){
          agentsRef.where({"status": "DeQueuing"}).orderBy( {"readytime":-1} ).on('value').then(function( data ){
               var agent = data.first().value();
               callback( agent.client );
          },function(err){
               agentsRef.where({"status": "Ready"}).orderBy( {"readytime":-1} ).on('value').then(function( data ){
                    var agent = data.first().value();
                    callback( agent.client );
               },function(err){
                    callback( false );
               });
          });
     }else{
          agentsRef.where({"status": "Ready"}).orderBy( {"readytime":-1} ).on('value').then(function( data ){
               var agent = data.first().value();
               callback( agent.client );
          },function(err){
               callback( false );
          });
     }
}
// check if the user exists and if they do then we update, otherwise we insert...
function update_agent(client, data, cb){
     var d = new Date();
     var date = d.toLocaleString();
     var callback = cb || null;
     agentsRef.where({"client": client}).once('value').then( function( rec ){
          var agent = rec.first().value();
          for( var i in data ){
               agent[i] = data[i];
          }
          agentsRef.push(agent, function(resp) {
               console.log( "agent updated" );
               if( callback !== null ){
                    callback();
               }
          });
     },function(err){
          data.client = client;
          agentsRef.push(data, function(resp) {
               console.log( "agent inserted" );
               if( callback !== null ){
                    callback();
               }
          });
     });
}
function update_call(sid, data){
     var d = new Date();
     var date = d.toLocaleString();
     callsRef.where({"sid": sid}).on('value').then( function( rec ){
          var call = rec.first().value();
          for( var i in data ){
               call[i] = data[i];
          }
          callsRef.push(call, function(resp) {
               console.log( "call updated" );
          });
     },function(err){
          data.sid = sid;
          callsRef.push(data, function(resp) {
               console.log( "call inserted" );
          });
     });
}
// call queue handling
var qSum = 0;
var checkQueue = function() {
     qSum += 1;
     var qSize = 0;
     var readyAgents = 0;
     var qname = config.twilio.queueName;
     client.queues(queueId).get(function(err, queue) {
          qSize = queue.currentSize;
          console.log( 'There are #' + qSize + ' callers in the queue (' + queueId + ')' );
          if( qSize > 0 ){
               agentsRef.where({"status": "Ready"}).orderBy( {"readytime":-1} ).on('value').then(function( agents ){
                    var readyAgents = agents.count();
                    var bestClient = agents.first().value();
                    console.log("Found best client - routing to #" + bestClient.client + " - setting agent to DeQueuing status so they aren't sent another call from the queue");
                    update_agent(bestClient.client, {status: "DeQueuing" }, function(){
                         console.log('redirecting call now!');
                         client.queues(queueId).members("Front").update({
                              url: config.twilio.dqueueurl,
                              method: "POST"
                         }, function(err, member) {
//                                   console.log(member.position);
                         });
                    });
               },function(err){
                    console.log("No Ready agents during queue poll #" + qSum);
               });
               agentsRef.trigger('agents-ready', readyAgents );
               agentsRef.trigger('in-queue', qSize );
               // restart the check checking
               setTimeout(checkQueue, 3000);
          }else{
               // restart the check checking
               console.log("No callers found during queue poll #" + qSum);
               setTimeout(checkQueue, 3000);
          }
     });
};
setTimeout(checkQueue, 1500);

Recapping the Code

There’s a lot happening in this file. First, we require our various libraries and set up express. Then we start our actual work.

You’ll notice we set up two Flybase references:
  • - callsRef connects to our calls table and handles storing and retrieving information for incoming calls.

  • - agentsRef connects to our agents table and handles storing and retrieving information for agents.

The first backend task we handle is checking our Twilio queues to retrieve the queueId or our call queue, or else create it if it doesn’t exist. We use this queue for storing incoming calls if there are no agents available in our call center, and they stay inside the queue until an agent is available.

Then we set up event listeners for two events:
  • - agent-removed: When an agent logs out, then we update their user record to set them to not ready.

  • - get-ready-agents: Just returns the number of agents currently set to Ready.

Then we have our actual URI endpoints:
  • - /dial is a POST request that is handled by Twilio to make outgoing calls between the agent’s web browser and a phone number.

  • - /voice is a POST request that handles incoming calls from phone numbers. This works by finding the agent that has had their status set to Ready for the longest time and assigning them to the call. If the agent is not Ready, then we place the caller in a queue and check it later.

  • - /handDialCallStatus is a POST request that is called when a call finishes. It checks to see if the call was answered or not answered and, depending on the DialCallStatus returned from Twilio, either places the caller back into the queue and takes the agent out of the Ready status or hangs up the call as it assumes the call is done.

  • - /token is a GET request called via an AJAX call on the frontend to assign a Twilio Client capability token to the agent while they are logged in.

  • - /getconfig is a GET request called also via an AJAX call from the client that returns the call center’s Flybase settings for the softphone to use in the frontend.

  • - / is a GET request that displays the softphone and assigns a name to the client based on the ?client query string.

We have three backbone functions that are used by the call center to handle various purposes:
  • - getLongestIdle is a function that checks for either an agent whose status is set to Ready or “DeQueuing” and returns that agent’s client name. In the case of no agents being found, then we return false, and it places the caller in the queue. “DeQueuing” is a special status we’ll be setting at the end of our code as an agent becomes available.

  • - update_agent will take the agent’s ID and update their account in the Flybase database with new info, such as status updates when on a call, going offline, etc.

  • - update_call is used in the same way as update_agent but for tracking calls.

Finally, we have the queue handling function called checkQueue, which is called 1.5 seconds after the app loads and then performs a simple task every 3 seconds:
  1. 1.

    It enters into a loop to return all callers in the call queue.

     
  2. 2.

    If there are callers waiting to connect to agents, then it will look for the agent with their status set to Ready and who has been Ready the longest by sorting by the readyTime field.

     
  3. 3.

    If an agent is Ready, then we set that agent’s status to DeQueuing and connect the caller at the Front of the queue to that agent by calling our dqueueurl.

     
  4. 4.

    If no agents are Ready or no callers are in the queue, then we set a timeout to call the function again in 3 seconds and return to step 1 of the “checkQueue” loop.

     
We next want to create a folder called “app” and then inside that folder create a file called config.js:
module.exports = {
     // Twilio API keys
     twilio: {
          sid: "ACCOUNT-SID",
          token: "AUTH-TOKEN",
          appid: 'APP-ID',
          fromNumber : "TWILIO-NUMBER",
          welcome : "Thank you for calling.",
          hangup : false,
          queueName: "cnacd",
          dqueueurl:"http://yourwebsite.com/voice"
     },
     //     Flybase settings
     flybase: {
          api_key: "YOUR-API-KEY",
          app_name: "YOUR-FLYBASE-APP"
     }
};

Update this file to contain your Twilio information and your Flybase information.

For the Twilio information, you’ll need to create a TwiML app inside your Twilio account. Create the app and have it POST to your call center website at /dial.

Also, create a new phone number inside Twilio and have that phone number POST to your call center website at /voice.

There is a variable called queueName , which is the name of the queue you want your call center to use, and also a variable called dqueueurl, which is the URL to your website with /voice appended to it. You will need this for the dequeuing task as Twilio requires an absolute URL.

The Softphone

Inside the app folder, create two folders:
  1. 1.

    views

     
  2. 2.

    public

     
Inside “public,” create a file called “index.html”:
<!DOCTYPE html>
<html>
<head>
     <title>Twilio Softphone</title>
     <script type="text/javascript" src="https://static.twilio.com/libs/twiliojs/1.2/twilio.min.js"></script>
     <script src="https://ajax.googleapis.com/ajax/libs/jquery/2.2.0/jquery.min.js"></script>
     <script src="https://na15.salesforce.com/support/api/31.0/interaction.js"></script>
     <script src="https://na15.salesforce.com/support/console/31.0/integration.js"></script>
     <script src="https://cdn.flybase.io/flybase.js"></script>
     <script type="text/javascript" src="/js/softphone.js"></script>
     <link rel="stylesheet" type="text/css" href="/css/dialer.css">
</head>
<body>
     <div id="client_name" hidden="true"><%= client_name %></div>
     <div id="softphone" class="softphone">
          <div id="agent-status-controls" class="clearfix">
               <button class="agent-status ready">Ready</button>
               <button class="agent-status not-ready">Not Ready</button>
               <div class="agent-status active">Call In-Progress</div>
          </div><!-- /agent-status -->
          <div id="agent-status">
               <p></p>
          </div> /agent-status -->
          <div class="divider"></div>
          <div id="number-entry">
               <input placeholder="+1 (555) 555-5555"></input>
               <div class="incoming-call-status">Incoming Call</div>
          </div><!-- /number-entry -->
          <div id="dialer">
               <div id="dialer-container">
                    <div class="numpad-container">
                         <div class="number" value="1">1</div><div class="number" value="2">2</div><div class="number" value="3">3</div><div class="number" value="4">4</div><div class="number" value="5">5</div><div class="number" value="6">6</div><div class="number" value="7">7</div><div class="number" value="8">8</div><div class="number" value="9">9</div><div class="number ast" value="*">&lowast;</div><div class="number" value="0">0</div><div class="number" value="#">#</div>
                    </div> /numpad-container -->
               </div><!-- /dialer-container -->
          </div><!-- /dialer -->
          <div id="action-button-container">
               <div id="action-buttons">
                    <button class="call">Call</button>
                    <button class="answer">Answer</button>
                    <button class="hangup">Hangup</button>
                    <button class="mute">Mute</button><button class="hold">Hold</button><button class="unhold">UnHold</button>
               </div><!-- /action-buttons -->
          </div><!---action-button-container -->
          <div id="call-data">
               <h3>Caller info</h3>
               <ul class="name"><strong>Name: </strong><span class="caller-name"></span></ul>
               <ul class="phone_number"><strong>Number: </strong><span class="caller-number"></span></ul>
               <ul class="queue"><strong>Queue: </strong><span class="caller-queue"></span></ul>
               <ul class="message"><strong>Message: </strong><span class="caller-message"></span></ul>
          </div><!-- /call-data -->
          <div id="callerid-entry" style="display:<%= anycallerid %>">
               <input placeholder="Change your Caller ID "></input>
          </div><!-- /number-entry -->
          <div id="team-status">
               <div class="agents-status"><div class="agents-num">-</div>Agents</div>
               <div class="queues-status"><div class="queues-num">-</div>In-Queue</div>
          </div><!-- /team-status -->
     </div><!-- /softphone -->
</body>
</html>

This is our index file, which handles the output of our softphone for agents to use to accept and make calls.

Inside the public folder, create a folder called “css” and include the following two files :

“dialer.css”:
/* reset css */
article,aside,details,figcaption,figure,footer,header,hgroup,hr,menu,nav,section{display:block}a,hr{padding:0}abbr,address,article,aside,audio,b,blockquote,body,canvas,caption,cite,code,dd,del,details,dfn,div,dl,dt,em,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,header,hgroup,html,i,iframe,img,ins,kbd,label,legend,li,mark,menu,nav,object,ol,p,pre,q,samp,section,small,span,strong,sub,summary,sup,table,tbody,td,tfoot,th,thead,time,tr,ul,var,video{margin:0;padding:0;border:0;outline:0;font-size:100%;vertical-align:baseline;background:0 0}ins,mark{background-color:#ff9;color:#000}body{line-height:1}nav ul{list-style:none}blockquote,q{quotes:none}blockquote:after,blockquote:before,q:after,q:before{content:'';content:none}a{margin:0;font-size:100%;vertical-align:baseline;background:0 0}ins{text-decoration:none}mark{font-style:italic;font-weight:700}del{text-decoration:line-through}abbr[title],dfn[title]{border-bottom:1px dotted;cursor:help}table{border-collapse:collapse;border-spacing:0}hr{height:1px;border:0;border-top:1px solid #ccc;margin:1em 0}input,select{vertical-align:middle}
.clearfix:before, .clearfix:after { content: " "; display: table; }
.clearfix:after { clear: both; }
.clearfix { *zoom: 1; }
*, *:before, *:after {
  -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box;
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  -khtml-user-select: none;
  -moz-user-select: none;
  -ms-user-select: none;
  user-select: none;
}
body {
  font-family: "Helvetica", Arial, sans-serif;
  background-color: white;
}
#softphone {
  width: 175px;
  margin: 10px auto 0px;
}
#agent-status-controls {
  margin: 10px 0 20px;
  position: relative;
}
.agent-status {
  border: none;
  padding: 6px 10px;
  background-image: linear-gradient(bottom, #ddd 20%, #eee 72%);
  background-image: -o-linear-gradient(bottom, #ddd 20%, #eee 72%);
  background-image: -moz-linear-gradient(bottom, #ddd 20%, #eee 72%);
  background-image: -webkit-linear-gradient(bottom, #ddd 20%, #eee 72%);
  background-image: -ms-linear-gradient(bottom, #ddd 20%, #eee 72%);
  background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0.2, #ddd), color-stop(0.72, #eee));
  color: #333;
  text-shadow: 0px -1px 0px rgba(255, 255, 255, 0.3);
  box-shadow: inset 0px 0px 1px rgba(0, 0, 0, 0.4);
  cursor: pointer;
  text-align: center;
}
button.agent-status {
  display: inline-block;
  float: left;
  width: 50%;
  margin: 0;
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
}
@-webkit-keyframes pulse {
  0% {background-color: #EA6045;}
  50% {background-color: #e54a23;}
  100% {background-color: #EA6045;}
}
div.agent-status {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 1000;
  font-size: 12px;
  line-height: 12px;
  background-image: none;
  background-color: #EA6045;
  -webkit-animation: pulse 1s infinite alternate;
  color: #fff;
  text-shadow: 0px -1px 0px rgba(0, 0, 0, 0.2);
  border-radius: 2px;
}
.agent-status:active, .agent-status:focus {
  outline: none;
}
.agent-status[disabled] {
  box-shadow: inset 0px 0px 15px rgba(0, 0, 0, 0.6);
  opacity: 0.8;
  text-shadow: 0px 1px 0px rgba(0, 0, 0, 0.4);
}
.agent-status.ready {
  border-radius: 2px 0 0 2px;
}
.agent-status.ready[disabled] {
  background-image: linear-gradient(bottom, #7eac20 20%, #91c500 72%);
  background-image: -o-linear-gradient(bottom, #7eac20 20%, #91c500 72%);
  background-image: -moz-linear-gradient(bottom, #7eac20 20%, #91c500 72%);
  background-image: -webkit-linear-gradient(bottom, #7eac20 20%, #91c500 72%);
  background-image: -ms-linear-gradient(bottom, #7eac20 20%, #91c500 72%);
  background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0.2, #7eac20), color-stop(0.72, #91c500));
  color: #f5f5f5;
}
.agent-status.not-ready {
  border-radius: 0 2px 2px 0;
}
.agent-status.not-ready[disabled] {
  background-image: linear-gradient(bottom, #e64118 20%, #e54a23 72%);
  background-image: -o-linear-gradient(bottom, #e64118 20%, #e54a23 72%);
  background-image: -moz-linear-gradient(bottom, #e64118 20%, #e54a23 72%);
  background-image: -webkit-linear-gradient(bottom, #e64118 20%, #e54a23 72%);
  background-image: -ms-linear-gradient(bottom, #e64118 20%, #e54a23 72%);
  background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0.2, #e64118), color-stop(0.72, #e54a23));
  color: #f5f5f5;
}
#dialer {
  border: solid 1px #ddd;
  border-width: 0 0 0 1px;
  -webkit-transition: opacity 1s;
  transition: opacity 1s;
}
input {
  border: solid 1px #ddd;
  border-bottom-color: #d5d5d5;
  border-radius: 2px 2px 0 0;
  font-size: 16px;
  width: 100%;
  padding: 14px 5px;
  display: block;
  text-align: center;
  margin: 0;
  position: relative;
  z-index: 100;
  -webkit-transition: border-color 1s;
  transition: border-color 1s;
}
#number-entry {
  position: relative;
  height: 48px;
}
.incoming input {
  border: solid 1px red;
}
.incoming #dialer {
  opacity: 0.25;
}
.softphone .incoming-call-status {
  position: absolute;
  display: none;
  top: 100%;
  left: 0;
  right: 0;
  background: red;
  color: #fff;
  font-size: 16px;
  padding: 6px 0;
  text-align: center;
  width: 100%;
  z-index: 200;
  border-radius: 0 0 2px 2px;
  opacity: 0;
  -webkit-transition: opacity 1s;
  transition: opacity 1s;
}
.incoming .incoming-call-status {
  display: block;
  opacity: 1;
}
.number {
  color: #555;
  font-weight: 300;
  cursor: pointer;
  display: inline-block;
  height: 38px;
  line-height: 38px;
  font-size: 21px;
  width: 33.333333333%;
  background-image: linear-gradient(bottom, #e9e9e9 20%, #e5e5e5 72%);
  background-image: -o-linear-gradient(bottom, #e9e9e9 20%, #e5e5e5 72%);
  background-image: -moz-linear-gradient(bottom, #e9e9e9 20%, #e5e5e5 72%);
  background-image: -webkit-linear-gradient(bottom, #e9e9e9 20%, #e5e5e5 72%);
  background-image: -ms-linear-gradient(bottom, #e9e9e9 20%, #e5e5e5 72%);
  background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0.2, #e9e9e9), color-stop(0.72, #e5e5e5));
  text-shadow: 0px 1px 0px #f5f5f5;
  filter: dropshadow(color=#f5f5f5, offx=0, offy=1);
  text-align: center;
  box-shadow: inset 1px 0px 0px rgba(255, 255, 255, 0.4),
    inset -1px 0px 0px rgba(0, 0, 0, 0.1),
    inset 0px 1px 0px #f5f5f5,
    inset 0 -1px 0px #d6d6d6;
}
.number.ast {
  font-size: 33px;
  line-height: 32px;
  vertical-align: -1px;
}
.number:hover {
  background-image: linear-gradient(bottom, #f5f5f5 20%, #f0f0f0 72%);
  background-image: -o-linear-gradient(bottom, #f5f5f5 20%, #f0f0f0 72%);
  background-image: -moz-linear-gradient(bottom, #f5f5f5 20%, #f0f0f0 72%);
  background-image: -webkit-linear-gradient(bottom, #f5f5f5 20%, #f0f0f0 72%);
  background-image: -ms-linear-gradient(bottom, #f5f5f5 20%, #f0f0f0 72%);
  background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0.2, #f5f5f5), color-stop(0.72, #f0f0f0));
}
.number:active {
  box-shadow: inset 1px 0px 0px rgba(255, 255, 255, 0.4),
    inset -1px 0px 0px rgba(0, 0, 0, 0.1),
    inset 0px 1px 0px #f5f5f5,
    inset 0 -1px 0px #d6d6d6,
    inset 0px 0px 5px 2px rgba(0, 0, 0, 0.15);
}
#action-buttons button {
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
  display: inline-block;
  border: none;
  margin: 0;
  cursor: pointer;
}
#action-buttons .call {
  color: #f5f5f5;
  width: 100%;
  font-size: 18px;
  padding: 8px 0;
  text-shadow: 0px -1px 0px rgba(0, 0, 0, 0.3);
  margin: 0;
  background-image: linear-gradient(bottom, #7eac20 20%, #91c500 72%);
  background-image: -o-linear-gradient(bottom, #7eac20 20%, #91c500 72%);
  background-image: -moz-linear-gradient(bottom, #7eac20 20%, #91c500 72%);
  background-image: -webkit-linear-gradient(bottom, #7eac20 20%, #91c500 72%);
  background-image: -ms-linear-gradient(bottom, #7eac20 20%, #91c500 72%);
  background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0.2, #7eac20), color-stop(0.72, #91c500));
  border-radius: 0 0 2px 2px;
}
#action-buttons .answer, #action-buttons .hangup {
  color: #f5f5f5;
  width: 100%;
  font-size: 18px;
  padding: 8px 0;
  text-shadow: 0px -1px 0px rgba(0, 0, 0, 0.4);
  margin: 0;
  background-image: linear-gradient(bottom, #e64118 20%, #e54a23 72%);
  background-image: -o-linear-gradient(bottom, #e64118 20%, #e54a23 72%);
  background-image: -moz-linear-gradient(bottom, #e64118 20%, #e54a23 72%);
  background-image: -webkit-linear-gradient(bottom, #e64118 20%, #e54a23 72%);
  background-image: -ms-linear-gradient(bottom, #e64118 20%, #e54a23 72%);
  background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0.2, #e64118), color-stop(0.72, #e54a23));
  border-radius: 0 0 2px 2px;
}
#action-buttons .hold, #action-buttons .unhold, #action-buttons .mute {
  color: #444;
  width: 50%;
  font-size: 14px;
  padding: 12px 0;
  text-shadow: 0px 1px 0px rgba(255, 255, 255, 0.3);
  margin: 0;
  background-image: linear-gradient(bottom, #bbb 20%, #ccc 72%);
  background-image: -o-linear-gradient(bottom, #bbb 20%, #ccc 72%);
  background-image: -moz-linear-gradient(bottom, #bbb 20%, #ccc 72%);
  background-image: -webkit-linear-gradient(bottom, #bbb 20%, #ccc 72%);
  background-image: -ms-linear-gradient(bottom, #bbb 20%, #ccc 72%);
  background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0.2, #bbb), color-stop(0.72, #ccc));
  box-shadow: inset 1px 0px 0px rgba(255, 255, 255, 0.4),
    inset -1px 0px 0px rgba(0, 0, 0, 0.1);
}
.mute {
  border-radius: 0 0 0 2px;
}
.hold, .unhold {
  border-radius: 0 2px 0 0;
}
#team-status .agents-status, #team-status .queues-status {
  display: inline-block;
  width: 45%;
  margin: 0;
  font-size: 14px;
  text-align: center;
  padding: 12px 0 16px;
  border-bottom: solid 1px #e5e5e5;
}
#team-status [class*="num"] {
  font-size: 32px;
  font-weight: bold;
  margin-bottom: 6px;
}
#call-data {
  display: none;
}
.powered-by {
  text-align: right;
  padding: 10px 0;
}
img {
  width: 100px;
}

Finally, we want to set up our softphone frontend code.

Softphone Frontend Code

Create a folder inside public called js and add softphone.js.

This code is based on the original softphone.js code that Charles wrote, but I’ve added in Flybase queries directly to the frontend and then set up event listeners:
```javascript
$(function() {
     // ** Application container ** //
     window.SP = {}
     // Global state
     SP.state = {};
     SP.agentsRef = {};
     SP.callsRef = {};
     SP.agent = {};
     SP.state.callNumber = null;
     SP.state.calltype = "";
     SP.username = $('#client_name').text();
     SP.currentCall = null;     //instance variable for tracking current connection
     SP.requestedHold = false; //set if agent requested hold button
     SP.functions = {};
     // Get a Twilio Client name and register with Twilio
     SP.functions.getTwilioClientName = function(sfdcResponse) {
          sforce.interaction.runApex('UserInfo', 'getUserName', '' , SP.functions.registerTwilioClient);
     }
     SP.functions.registerTwilioClient = function(response) {
          console.log("Registering with client name: " + response.result);
          // Twilio does not accept special characters in Client names
          var useresult = response.result;
          useresult = useresult.replace("@", "AT");
          useresult = useresult.replace(".", "DOT");
          SP.username = useresult;
          console.log("useresult = " + useresult);
          $.get("/getconfig", {"client":SP.username}, function (data) {
               if( typeof data.api_key !== 'undefined' ){
                    // agents...
                    SP.agentsRef = new Flybase( data.api_key, data.app_name, 'agents');
                    SP.agentsRef.isReady( function(){
                         SP.functions.startWebSocket();
                    });
                    // calls...
                    SP.callsRef = new Flybase( data.api_key, data.app_name, 'calls');
               }else{
                    console.log( "umm yeah, something's broken. Please fix it");
               }
          });
          $.get("/token", {"client":SP.username}, function (token) {
               Twilio.Device.setup(token, {debug: true});
          });
          $.get("/getCallerId", { "from":SP.username}, function(data) {
               $("#callerid-entry > input").val(data);
          });
     }
     SP.functions.startWebSocket = function() {
          // ** Agent Presence Stuff ** //
          console.log(".startWebSocket...");
          var d = new Date();
          var date = d.toLocaleString();
//          look up or add agent:
          SP.functions.update_agent(SP.username,{
               status: 'LoggingIn',
               readytime: date
          });
          SP.agentsRef.on('agents-ready', function (data) {
               $("#team-status .agents-num").text( data );
          });
          SP.agentsRef.on('in-queue', function (data) {
               $("#team-status .queues-num").text( data);
          });
          SP.agentsRef.onDisconnect( function(){
               // if the agent gets disconnected for any reason, then we want to kick them offline...
               SP.agentsRef.trigger('agent-removed',{username: SP.username});
          });
     }
//     update or insert agent.. don't keep re-adding the same agent..
     SP.functions.update_agent = function(client, data){
          var d = new Date();
          var date = d.toLocaleString();
          SP.agentsRef.where({"client": client}).once('value').then(function( rec ){
               var agent = rec.first().value();
               for( var i in data ){
                    agent[i] = data[i];
               }
               SP.agent = agent;
               SP.agentsRef.push(agent, function(resp) {
                    console.log( "agent updated" );
               });
          }, function(err){
               data.client = client;
               SP.agent = data;
               SP.agentsRef.push(data, function(resp) {
                    console.log( "agent inserted" );
               });
          });
     }
     // ** UI Widgets ** //
     // Hook up numpad to input field
     $("div.number").bind('click',function(){
          //$("#number-entry > input").val($("#number-entry > input").val()+$(this).attr('Value'));
          //pass key without conn to a function
          SP.functions.handleKeyEntry($(this).attr('Value'));
     });
     SP.functions.handleKeyEntry = function (key) {
           if (SP.currentCall != null) {
               console.log("sending DTMF" + key);
               SP.currentCall.sendDigits(key);
           } else {
                $("#number-entry > input").val($("#number-entry > input").val()+key);
           }
     }
     //called when agent is not on a call
     SP.functions.setIdleState = function() {
          $("#action-buttons > .call").show();
          $("#action-buttons > .answer").hide();
          $("#action-buttons > .mute").hide();
          $("#action-buttons > .hold").hide();
          $("#action-buttons > .unhold").hide();
          $("#action-buttons > .hangup").hide();
          $('div.agent-status').hide();
          $("#number-entry > input").val("");
     }
     SP.functions.setRingState = function () {
          $("#action-buttons > .answer").show();
          $("#action-buttons > .call").hide();
          $("#action-buttons > .mute").hide();
          $("#action-buttons > .hold").hide();
          $("#action-buttons > .unhold").hide();
          $("#action-buttons > .hangup").hide();
     }
     SP.functions.setOnCallState = function() {
          $("#action-buttons > .answer").hide();
          $("#action-buttons > .call").hide();
          $("#action-buttons > .mute").show();
          //can not hold outbound calls, so disable this
          if (SP.calltype == "Inbound") {
               $("#action-buttons > .hold").show();
          }
          $("#action-buttons > .hangup").show();
          $('div.agent-status').show();
     }
     // Hide caller info
     SP.functions.hideCallData = function() {
          $("#call-data").hide();
     }
     SP.functions.hideCallData();
     SP.functions.setIdleState();
     // Show caller info
     SP.functions.showCallData = function(callData) {
          $("#call-data > ul").hide();
          $(".caller-name").text(callData.callerName);
          $(".caller-number").text(callData.callerNumber);
          $(".caller-queue").text(callData.callerQueue);
          $(".caller-message").text(callData.callerMessage);
          if (callData.callerName) {
               $("#call-data > ul.name").show();
          }
          if (callData.callerNumber) {
               $("#call-data > ul.phone_number").show();
          }
          if (callData.callerQueue) {
               $("#call-data > ul.queue").show();
          }
          if (callData.callerMessage) {
               $("#call-data > ul.message").show();
          }
          $("#call-data").slideDown(400);
     }
     // Attach answer button to an incoming connection object
     SP.functions.attachAnswerButton = function(conn) {
          $("#action-buttons > button.answer").click(function() {
          conn.accept();
          }).removeClass('inactive').addClass("active");
     }
     SP.functions.detachAnswerButton = function() {
          $("#action-buttons > button.answer").unbind().removeClass('active').addClass("inactive");
     }
     SP.functions.attachMuteButton = function(conn) {
          $("#action-buttons > button.mute").click(function() {
          conn.mute();
          SP.functions.attachUnMute(conn);
          }).removeClass('inactive').addClass("active").text("Mute");
     }
     SP.functions.attachUnMute = function(conn) {
          $("#action-buttons > button.mute").click(function() {
          conn.unmute();
          SP.functions.attachMuteButton(conn);
          }).removeClass('inactive').addClass("active").text("UnMute");
     }
     SP.functions.detachMuteButton = function() {
          $("#action-buttons > button.mute").unbind().removeClass('active').addClass("inactive");
     }
     SP.functions.attachHoldButton = function(conn) {
          $("#action-buttons > button.hold").click(function() {
           console.dir(conn);
           SP.requestedHold = true;
           //can't hold outbound calls from Twilio client
           $.post("/request_hold", { "from":SP.username, "callsid":conn.parameters.CallSid, "calltype":SP.calltype }, function(data) {
                //Todo: handle errors
                //Todo: change status in future
                SP.functions.attachUnHold(conn, data);
               });
          }).removeClass('inactive').addClass("active").text("Hold");
     }
     SP.functions.attachUnHold = function(conn, holdid) {
          $("#action-buttons > button.unhold").click(function() {
          //do ajax request to hold for the conn.id
           $.post("/request_unhold", { "from":SP.username, "callsid":holdid }, function(data) {
                //Todo: handle errors
                //Todo: change status in future
                //SP.functions.attachHoldButton(conn);
               });
          }).removeClass('inactive').addClass("active").text("UnHold").show();
     }
     SP.functions.detachHoldButtons = function() {
          $("#action-buttons > button.unhold").unbind().removeClass('active').addClass("inactive");
          $("#action-buttons > button.hold").unbind().removeClass('active').addClass("inactive");
     }
     SP.functions.updateAgentStatusText = function(statusCategory, statusText, inboundCall) {
          if (statusCategory == "ready") {
                $("#agent-status-controls > button.ready").prop("disabled",true);
                $("#agent-status-controls > button.not-ready").prop("disabled",false);
                $("#agent-status").removeClass();
                $("#agent-status").addClass("ready");
                $('#softphone').removeClass('incoming');
          }
          if (statusCategory == "notReady") {
                $("#agent-status-controls > button.ready").prop("disabled",false);
                $("#agent-status-controls > button.not-ready").prop("disabled",true);
                $("#agent-status").removeClass();
                $("#agent-status").addClass("not-ready");
                $('#softphone').removeClass('incoming');
          }
          if (statusCategory == "onCall") {
               $("#agent-status-controls > button.ready").prop("disabled",true);
               $("#agent-status-controls > button.not-ready").prop("disabled",true);
               $("#agent-status").removeClass();
               $("#agent-status").addClass("on-call");
               $('#softphone').removeClass('incoming');
          }
          if (inboundCall ==     true) {
          //alert("call from " + statusText);
          $('#softphone').addClass('incoming');
          $("#number-entry > input").val(statusText);
          }
          //$("#agent-status > p").text(statusText);
     }
     // Call button will make an outbound call (click to dial) to the number entered
     $("#action-buttons > button.call").click( function( ) {
          params = {"PhoneNumber": $("#number-entry > input").val(), "CallerId": $("#callerid-entry > input").val()};
          Twilio.Device.connect(params);
     });
     // Hang up button will hang up any active calls
     $("#action-buttons > button.hangup").click( function( ) {
          Twilio.Device.disconnectAll();
     });
     // Wire the ready / not ready buttons up to the server-side status change functions
     $("#agent-status-controls > button.ready").click( function( ) {
          $("#agent-status-controls > button.ready").prop("disabled",true);
          $("#agent-status-controls > button.not-ready").prop("disabled",false);
          SP.functions.ready();
     });
     $("#agent-status-controls > button.not-ready").click( function( ) {
          $("#agent-status-controls > button.ready").prop("disabled",false);
          $("#agent-status-controls > button.not-ready").prop("disabled",true);
          SP.functions.notReady();
     });
     $("#agent-status-controls > button.userinfo").click( function( ) {
     });
     // ** Twilio Client Stuff ** //
     // first register outside of sfdc
     if ( window.self === window.top ) {
          console.log("Not in an iframe, assume we are using default client");
          var defaultclient = {}
          defaultclient.result = SP.username;
          SP.functions.registerTwilioClient(defaultclient);
     } else{
          console.log("In an iframe, assume it is Salesforce");
          sforce.interaction.isInConsole(SP.functions.getTwilioClientName);
     }
     //this will only be called inside of salesforce
     Twilio.Device.ready(function (device) {
          sforce.interaction.cti.enableClickToDial();
          sforce.interaction.cti.onClickToDial(startCall);
          var adNag = function() {
               SP.functions.ready();
          };
          setTimeout(adNag, 1500);
     });
     Twilio.Device.offline(function (device) {
          //make a new status call.. something like.. disconnected instead of notReady ?
          sforce.interaction.cti.disableClickToDial();
          SP.functions.notReady();
          SP.functions.hideCallData();
     });
     /* Report any errors on the screen */
     Twilio.Device.error(function (error) {
          SP.functions.updateAgentStatusText("ready", error.message);
          SP.functions.hideCallData();
     });
     /* Log a message when a call disconnects. */
     Twilio.Device.disconnect(function (conn) {
          console.log("disconnecting...");
          SP.functions.updateAgentStatusText("ready", "Call ended");
          SP.state.callNumber = null;
          // deactivate answer button
          SP.functions.detachAnswerButton();
          SP.functions.detachMuteButton();
          SP.functions.detachHoldButtons();
          SP.functions.setIdleState();
          SP.currentCall = null;
          // return to waiting state
          SP.functions.hideCallData();
          SP.functions.ready();
          //sforce.interaction.getPageInfo(saveLog);
     });
     Twilio.Device.connect(function (conn) {
          console.dir(conn);
          var     status = "";
          var callNum = null;
          if (conn.parameters.From) {
               callNum = conn.parameters.From;
               status = "Call From: " + callNum;
               SP.calltype = "Inbound";
          } else {
               status = "Outbound call";
               SP.calltype = "Outbound";
          }
          console.dir(conn);
          SP.functions.updateAgentStatusText("onCall", status);
          SP.functions.setOnCallState();
          SP.functions.detachAnswerButton();
          SP.currentCall = conn;
          SP.functions.attachMuteButton(conn);
          SP.functions.attachHoldButton(conn, SP.calltype);
          //send status info
          SP.functions.update_agent(SP.username,{
               status: 'OnCall'
          });
     });
     /* Listen for incoming connections */
     Twilio.Device.incoming(function (conn) {
          // Update agent status
          sforce.interaction.setVisible(true);     //pop up CTI console
          SP.functions.updateAgentStatusText("ready", ( conn.parameters.From), true);
          // Enable answer button and attach to incoming call
          SP.functions.attachAnswerButton(conn);
          SP.functions.setRingState();
          if (SP.requestedHold == true) {
               //auto answer
               SP.requestedHold = false;
               $("#action-buttons > button.answer").click();
          }
          var inboundnum = cleanInboundTwilioNumber(conn.parameters.From);
          var sid = conn.parameters.CallSid
          var result = "";
          //sfdc screenpop fields are specific to new contact screenpop
          sforce.interaction.searchAndScreenPop(inboundnum, 'con10=' + inboundnum + '&con12=' + inboundnum + '&name_firstcon2=' + name,'inbound');
     });
     Twilio.Device.cancel(function(conn) {
          console.log(conn.parameters.From); // who canceled the call
          SP.functions.detachAnswerButton();
          SP.functions.detachHoldButtons();
          SP.functions.hideCallData();
          SP.functions.notReady();
          SP.functions.setIdleState();
          $(".number").unbind();
          SP.currentCall = null;
          //SP.functions.updateStatus();
     });
     $("#callerid-entry > input").change( function() {
          $.post("/setcallerid", { "from":SP.username, "callerid": $("#callerid-entry > input").val() });
     });
     // Set server-side status to ready / not-ready
     SP.functions.notReady = function() {
          SP.functions.update_agent(SP.username,{
               status: 'NotReady'
          });
          SP.agentsRef.trigger('get-ready-agents',{username: SP.username});
          SP.functions.updateStatus();
     }
     SP.functions.ready = function() {
          SP.functions.update_agent(SP.username,{
               status: 'Ready'
          });
          SP.agentsRef.trigger('get-ready-agents',{username: SP.username});
          SP.functions.updateStatus();
     }
     // Check the status on the server and update the agent status dialog accordingly
     SP.functions.updateStatus = function() {
          var data = SP.agent.status;
          if (data == "NotReady" || data == "Missed") {
               SP.functions.updateAgentStatusText("notReady", "Not Ready")
          }
          if (data == "Ready") {
               SP.functions.updateAgentStatusText("ready", "Ready")
          }
     }
     /******** GENERAL FUNCTIONS for SFDC    *****************/
     function cleanInboundTwilioNumber(number) {
          //twilio inbound calls are passed with +1 (number). SFDC only stores
          return number.replace('+1','');
     }
     function cleanFormatting(number) {
          //changes a SFDC formatted US number, which would be 415-555-1212
          return number.replace(' ','').replace('-','').replace('(','').replace(')','').replace('+','');
     }
     function startCall(response) {
          //called onClick2dial
          sforce.interaction.setVisible(true);     //pop up CTI console
          var result = JSON.parse(response.result);
          var cleanedNumber = cleanFormatting(result.number);
          params = {"PhoneNumber": cleanedNumber, "CallerId": $("#callerid-entry > input").val()};
          Twilio.Device.connect(params);
     }
     var saveLogcallback = function (response) {
          if (response.result) {
               console.log("saveLog result =" + response.result);
          } else {
               console.log("saveLog error = " + response.error);
          }
     };
     function saveLog(response) {
          console.log("saving log result, response:");
          var result = JSON.parse(response.result);
          console.log(response.result);
          var timeStamp = new Date().toString();
          timeStamp = timeStamp.substring(0, timeStamp.lastIndexOf(':') + 3);
          var currentDate = new Date();
          var currentDay = currentDate.getDate();
          var currentMonth = currentDate.getMonth()+1;
          var currentYear = currentDate.getFullYear();
          var dueDate = currentYear + '-' + currentMonth + '-' + currentDay;
          var saveParams = 'Subject=' + SP.calltype +' Call on ' + timeStamp;
          saveParams += '&Status=completed';
          saveParams += '&CallType=' + SP.calltype;     //should change this to reflect actual inbound or outbound
          saveParams += '&Activitydate=' + dueDate;
          saveParams += '&Phone=' + SP.state.callNumber;     //we need to get this from.. somewhere
          saveParams += '&Description=' + "test description";
          console.log("About to parse     result..");
          var result = JSON.parse(response.result);
          var objectidsubstr = result.objectId.substr(0,3);
          // object id 00Q means a lead.. adding this to support logging on leads as well as contacts.
          if(objectidsubstr == '003' || objectidsubstr == '00Q') {
               saveParams += '&whoId=' + result.objectId;
          } else {
               saveParams += '&whatId=' + result.objectId;
          }
          console.log("save params = " + saveParams);
          sforce.interaction.saveLog('Task', saveParams, saveLogcallback);
     }
});
```
Once we set up our softphone, we make three AJAX calls to our backend:
  1. 1.

    /getconfig to return our Flybase info and enable our agentsRef and callsRef variables. Once agentsRef returns isReady from Flybase, then we trigger a call to our startWebSocket function. isReady is a function that we can use with the Flybase client when we wait to until our connection has been established before performing other actions.

     
  2. 2.

    /token to which we pass the agent’s name and which returns a Twilio capability token to let the agent make and receive calls.

     
  3. 3.

    /getCallerId to return the outgoing phone number for the call to use.

     

We use the startWebSocket function (which was based on the original) to set up three event listeners and to update the agent’s status as LogginIn and the time they came online.

Later in the Twilio Client code, we set the agent to Ready once their Twilio Client connection has been set up:
Twilio.Device.ready(function (device) {
     sforce.interaction.cti.enableClickToDial();
     sforce.interaction.cti.onClickToDial(startCall);
     var adNag = function() {
          SP.functions.ready();
     };
     setTimeout(adNag, 1500);
});

We are going to listen for agents-ready and in-queue events from our backend to tell the softphone to update the display to show the number of agents who are set to Ready and waiting for a call and then the number of callers who are in the queue waiting for an agent.

Finally, we’re going to use the onDisconnect event to fire off an agent-removed trigger when the agent goes offline for some reason, such as closing the browser, logging off, etc.

You’ll also notice a clone of our update_agent function in this file. One of the nice things about using Flybase is we can handle our database updates from either the frontend or the backend, so that lets us do a lot that we couldn’t before.

The rest of the softphone.js file is actually the same as it was before. It talks to Twilio Client on incoming and outgoing calls, and it either gets the client name from the?client query string or it gets it from Salesforce, if you are displaying your softphone inside Salesforce.

You may also notice we make use of our new promises (http://blog.flybase.io/2016/02/02/promises-lookups/) functionality:
SP.functions.update_agent = function(client, data){
          var d = new Date();
          var date = d.toLocaleString();
          SP.agentsRef.where({"client": client}).once('value').then(function( rec ){
               var agent = rec.first().value();
               for( var i in data ){
                    agent[i] = data[i];
               }
               SP.agent = agent;
               SP.agentsRef.push(agent, function(resp) {
                    console.log( "agent updated" );
               });
          }, function(err){
               data.client = client;
               SP.agent = data;
               SP.agentsRef.push(data, function(resp) {
                    console.log( "agent inserted" );
               });
          });
     }
SP.functions.update_agent = function(client, data){
          var d = new Date();
          var date = d.toLocaleString();
          SP.agentsRef.where({"client": client}).once('value').then(function( rec ){
               var agent = rec.first().value();
               for( var i in data ){
                    agent[i] = data[i];
               }
               SP.agent = agent;
               SP.agentsRef.push(agent, function(resp) {
                    console.log( "agent updated" );
               });
          }, function(err){
               data.client = client;
               SP.agent = data;
               SP.agentsRef.push(data, function(resp) {
                    console.log( "agent inserted" );
               });
          });
     }

In update_agent, we use promises to either return an existing agent record so we can update or create a brand-new record.

Deploying to Heroku (Optional)

This step is optional, and you can deploy anywhere you like.

You’ll want a Heroku account and also to have the Heroku Toolbelt (https://toolbelt.heroku.com/) installed.

Create a file called “Profile” and include
web: node app.js
Now, run the following:
  1. 1.

    git init

     
  2. 2.

    heroku login to log into Heroku

     
  3. 3.

    heroku create to create the application within Heroku

     
  4. 4.

    git add --all . to add all of your new files to the repo

     
  5. 5.

    git commit -am 'first commit' to store the files inside the repo

     
  6. 6.

    git push heroku master to push your git repository to Heroku

     
  7. 7.

    heroku open to open your browser at your new, custom URL

     

The call center is now working. You can add ?client=ANYNAMEYOUWANT to the end of the URL, and it will set you up as the agent.

Configuring Salesforce (Optional)

This step is optional. The call center works without Salesforce, and in part 2, we’ll build a basic CRM that you can integrate this into as well.

This part is actually pretty simple. First, create a file called “TwilioAdapter.xml”:
<?xml version="1.0" encoding="UTF-8" ?>
<callCenter>
  <section sortOrder="0" name="reqGeneralInfo" label="General Information">
    <item sortOrder="0" name="reqInternalName" label="InternalName">DemoAdapter</item>
    <item sortOrder="1" name="reqDisplayName" label="Display Name">Demo Call Center Adapter</item>
    <item sortOrder="2" name="reqAdapterUrl" label="CTI Adapter URL">http://YOURWEBSITE.com</item>
    <item sortOrder="3" name="reqUseApi" label="Use CTI API">true</item>
    <item sortOrder="4" name="reqSoftphoneHeight" label="Softphone Height">400</item>
    <item sortOrder="5" name="reqSoftphoneWidth" label="Softphone Width">300</item>
  </section>
  <section sortOrder="1" name="reqDialingOptions" label="Dialing Options">
    <item sortOrder="0" name="reqOutsidePrefix" label="Outside Prefix">9</item>
    <item sortOrder="1" name="reqLongDistPrefix" label="Long Distance Prefix">1</item>
    <item sortOrder="2" name="reqInternationalPrefix" label="International Prefix">01</item>
  </section>
</callCenter>
Change the appropriate info to point to your website and then follow these steps:
  1. 1.

    Go to Call Centers ➤ Create:

    - Import a call center, config included, TwilioAdapter.xml. After import, change the parameter CTI Adapter URL to the Heroku URL created in the first steps: https:/<insert yourherokuappurl.

    - Add yourself to the call center under “Manage Call Center Users” ➤ Add More Users ➤ Find.

     
  2. 2.

    You should now see a CTI adapter under the Contact tab. However, you want to use the Service Cloud Console for all CTI calls (which prevents browser refreshes that would hang up calls).

     
  3. 3.

    To create a Service Cloud Console

    - Go to Setup ➤ Create ➤ Apps ➤ New.

    - Choose “Console” for the type of app.

    - Give it a name, such as “Twilio ACD.”

    - Accept default for logo.

    - For tabs, add some tabs to your Service Cloud Console, such as Contacts, Cases, etc.

    - Accept default for step 5, “Choose how records display.”

    - Set visibility to all (for dev orgs).

    - You’ve now created an app! You will see your console in the App dropdown, for example, “Twilio ACD.”

     
  4. 4.

    Configure screenpops:

    - You can configure a screenpop response, such as to pop the search screen, in Setup ➤ Call Centers ➤ (your call center) ➤ Softphone Layout.

     

These steps were borrowed from Charles’ original post as they haven’t changed.

Summary

Now you’ve got a working real-time call center ACD system that can be used stand-alone (as a lone softphone), in a CRM such as Salesforce, or in a CRM built entirely around it, which we’ll do in part 2. If you’re familiar at all with the original client-acd, then not much has changed, other than being rewritten in Node and using Flybase as the backend/signal system, and that was the plan with this chapter as I wanted to demonstrate how Flybase can be used within a call center, and this one has always been a go-to for various projects.

Just a reminder, you can find the full source code here: https://github.com/flybaseio/callcenter.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
34.238.143.70