Skip to content

Latest commit

 

History

History
1631 lines (1332 loc) · 60.8 KB

File metadata and controls

1631 lines (1332 loc) · 60.8 KB

五、构建 Salesforce 支持的呼叫中心

在电话领域,自动呼叫分配(ACD)系统是一种根据客户的选择、客户的电话号码、所选的系统呼入线路或一天中处理呼叫的时间将呼入分配给特定代理组的系统。我们也称之为呼叫中心。

几年前,Twilio 的 Charles Oppenheimer ( https://github.com/choppen5 )使用 Twilio 客户端和 Ruby 构建了一个 Salesforce 嵌入式 ACD ( https://github.com/choppen5/client-acd )的演示。在这方面,查尔斯功不可没。

我们只是将 Charles 的演示转换成 Node.js,并使用 Flybase 驱动的后端来处理调用的分发,而不是原来的 Ruby/Mongo 系统。结果是一个更干净的呼叫中心,易于修改和集成到其他 CRM 中。

必要的工具

  • Flybase.io ( https://flybase.io/ )作为我们的后端,处理存储数据、传递事件和我们的调用队列。

  • Twilio 客户端( www.twilio.com/webrtc ),一个给 Twilio 的 WebRTC 接口。在我们的演示中,我们使用 JavaScript 库,该库为我们提供了一个 API 并连接到 Twilio,以便在我们的 Salesforce 浏览器中通过 WebRTC 接收呼叫。Twilio Client 还让我们能够通过软电话控制通话。

  • Heroku 将作为我们的网络主机,但你可以在任何你喜欢的地方托管你的呼叫中心。

  • Salesforce Open CTI ( https://developer.salesforce.com/page/Open_CTI )是一个开放的 API,允许第三方 CTI 供应商将电话渠道连接到 Salesforce CRM 界面。在我们的演示中,我们使用开放式 CTI 来容纳我们的软电话并驱动点击拨号/文本功能。由于开放式 CTI 的设计,该演示不需要插件或安装软件。

实际的 Salesforce 集成是可选的,您可以轻松地将您的软电话插入到另一个 CRM 中。本教程的第 2 部分实际上将使用 Flybase 构建一个简单的 CRM,并将 softphone 作为一个小部件包含在内。

入门指南

你可以在这里找到完整源代码: https://github.com/flybaseio/callcenter

首先,让我们设置我们的 Node.js 应用。

创建“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"
  }
}

这将告诉我们的呼叫中心,我们希望为我们的 Node 应用安装什么模块。现在,我们想要创建我们的“app.js”文件来处理我们所有的后端工作:

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);

重述代码

这个文件里发生了很多事情。首先,我们需要我们的各种图书馆,并建立快递。然后我们开始实际工作。

您会注意到我们设置了两个 Flybase 参考:

    • callsRef连接到我们的 calls 表,并处理来电信息的存储和检索。
    • agentsRef连接到我们的agents表,并为代理处理存储和检索信息。

我们处理的第一个后端任务是检查我们的 Twilio 队列,以检索 queueId 或我们的调用队列,否则如果它不存在,就创建它。如果我们的呼叫中心没有可用的代理,我们使用这个队列来存储来电,这些来电将留在队列中,直到有代理可用。

然后,我们为两个事件设置事件侦听器:

    • agent-removed:当代理注销时,我们更新他们的用户记录,将他们设置为not ready
    • get-ready-agents:只返回当前设置为Ready的代理数量。

然后我们有了实际的 URI 端点:

    • /dial是一个由 Twilio 处理的POST请求,用于在代理的 web 浏览器和电话号码之间发出呼叫。
    • /voice是一个POST请求,处理来自电话号码的来电。其工作原理是找到其状态设置为Ready时间最长的代理,并将其分配给呼叫。如果代理不是Ready,那么我们将调用者放在一个队列中,稍后检查它。
    • /handDialCallStatus 是在呼叫结束时调用的POST请求。它检查呼叫是否被应答,并根据从 Twilio 返回的DialCallStatus,要么将呼叫者放回队列并使代理脱离Ready状态,要么挂断呼叫,因为它认为呼叫已经完成。
    • /token是通过前端的 AJAX 调用调用的GET请求,用于在代理登录时向代理分配 Twilio 客户端功能令牌。
    • /getconfig是一个GET请求,也是通过 AJAX 调用从客户端调用的,它返回呼叫中心的 Flybase 设置,供软电话在前端使用。
    • /是一个GET请求,它显示软电话并根据?client查询字符串为客户机分配一个名称。

呼叫中心使用三种主干功能来处理各种目的:

    • getLongestIdle 是一个函数,用于检查状态设置为Ready或“ 出列 ”的代理,并返回该代理的客户端名称。在没有找到代理的情况下,我们返回 false,并将调用者放入队列中。“出列”是一种特殊的状态,当代理可用时,我们将在代码末尾设置这种状态。
    • update_agent 将获取代理的 ID,并用新信息更新他们在 Flybase 数据库中的帐户,例如通话时的状态更新、离线等。
    • update_call 的使用方式与update_agent相同,但用于追踪通话。

最后,我们有一个名为 checkQueue 的队列处理函数,它在应用加载 1.5 秒后被调用,然后每 3 秒执行一个简单的任务:

  1. 它进入一个循环,返回呼叫队列中的所有呼叫者。

  2. 如果有呼叫者等待连接到代理,那么它将通过按readyTime字段排序来寻找其状态设置为Ready的代理以及谁已经Ready最长。

  3. 如果代理是Ready,那么我们将该代理的状态设置为出列,并通过调用我们的dqueueurl将队列中的Front处的调用者连接到该代理。

  4. 如果没有代理Ready或者队列中没有调用者,那么我们设置一个超时,在 3 秒钟内再次调用该函数,并返回到“checkQueue”循环的步骤 1。

我们接下来要创建一个名为“app”的文件夹,然后在该文件夹中创建一个名为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"
     }
};

更新此文件以包含您的 Twilio 信息和您的 Flybase 信息。

对于 Twilio 信息,您需要在您的 Twilio 帐户中创建一个 TwiML 应用。创建应用,并在/dial将其POST发布到您的呼叫中心网站。

此外,在 Twilio 中创建一个新的电话号码,并将该电话号码POST发送到您的呼叫中心网站/voice

有一个名为queueName的变量,它是您希望呼叫中心使用的队列的名称,还有一个名为dqueueurl的变量,它是您的网站的 URL,后面附有/voice。因为 Twilio 需要一个绝对 URL,所以您将需要它来执行出队任务。

软电话

app文件夹中,创建两个文件夹:

  1. views

  2. public

在“public”中,创建一个名为“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>

这是我们的索引文件,它处理我们的软电话的输出,供座席用来接听和拨打电话。

public文件夹中,创建一个名为“css”的文件夹,并包含以下两个文件:

“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;
}

最后,我们要设置我们的软电话前端代码。

软电话前端代码

public中创建一个名为js的文件夹,并添加softphone.js

这段代码基于 Charles 编写的原始softphone.js代码,但是我已经直接在前端添加了 Flybase 查询,然后设置了事件监听器:

```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);
     }
});
```js

设置好软电话后,我们向后端发出三个 AJAX 调用:

  1. /getconfig返回我们的飞行基地信息,并启用我们的agentsRefcallsRef变量。一旦agentsRef从 Flybase 返回isReady,我们就触发对startWebSocket函数的调用。isReady是一个函数,当我们在执行其他操作之前等待直到我们的连接已经建立时,我们可以使用这个函数。

  2. 我们将代理的名字传递给它,它返回一个 Twilio 能力令牌,让代理发出和接收呼叫。

  3. /getCallerId返回呼出电话号码供通话使用。

我们使用startWebSocket函数(基于原始函数)来设置三个事件监听器,并将代理的状态更新为LogginIn以及它们上线的时间。

在稍后的 Twilio 客户端代码中,一旦 Twilio 客户端连接建立,我们就将代理设置为Ready:

Twilio.Device.ready(function (device) {
     sforce.interaction.cti.enableClickToDial();
     sforce.interaction.cti.onClickToDial(startCall);
     var adNag = function() {
          SP.functions.ready();
     };
     setTimeout(adNag, 1500);
});

我们将从后端监听agents-readyin-queue事件,以告知软电话更新显示,显示设置为Ready并等待呼叫的代理人数,以及排队等待代理的呼叫者人数。

最后,我们将使用onDisconnect事件在代理由于某种原因离线时触发agent-removed触发器,比如关闭浏览器、注销等。

您还会注意到我们的 update_agent 函数在这个文件中的克隆。使用 Flybase 的一个好处是,我们可以从前端或后端处理数据库更新,这样我们就可以做很多以前做不到的事情。

softphone.js文件的其余部分实际上和以前一样。它与 Twilio 客户端就传入和传出呼叫进行对话,如果您在 Salesforce 中显示您的软电话,它或者从?client查询字符串中获取客户端名称,或者从 Salesforce 中获取。

您可能还注意到我们使用了新的承诺( http://blog.flybase.io/2016/02/02/promises-lookups/ )功能:

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" );
               });
          });
     }

update_agent中,我们使用承诺来返回现有的代理记录,以便我们可以更新或创建一个全新的记录。

部署到 Heroku(可选)

这一步是可选的,您可以在任何您喜欢的地方部署。

你需要一个 Heroku 帐号,并且安装 Heroku Toolbelt ( https://toolbelt.heroku.com/ )。

创建一个名为“Profile”的文件,包括

web: node app.js

现在,运行以下命令:

  1. git init

  2. 登录 Heroku

  3. 在 Heroku 中创建应用

  4. git add --all .将您的所有新文件添加到存储库中

  5. git commit -am 'first commit'将文件存储在回购内

  6. 将您的 git 库推送到 Heroku

  7. heroku open在新的自定义网址打开浏览器

呼叫中心现在正在工作。您可以在 URL 的末尾添加?client=ANYNAMEYOUWANT,它会将您设置为代理。

配置 Salesforce(可选)

这一步是可选的。呼叫中心没有 Salesforce 也能工作,在第 2 部分中,我们将构建一个基本的 CRM,您也可以将它集成到其中。

这部分其实很简单。首先,创建一个名为“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>

更改适当的信息以指向您的网站,然后按照以下步骤操作:

  1. 转到➤呼叫中心创建:

    -导入呼叫中心,包括配置,TwilioAdapter.xml。导入后,将参数 CTI Adapter URL 更改为在第一步中创建的 Heroku URL:https:/<insert yourherokuappurl.

    -将您自己添加到“管理呼叫中心用户”下的呼叫中心➤添加更多用户➤查找。

  2. 现在,您应该在 Contact 选项卡下看到一个 CTI 适配器。但是,您希望对所有 CTI 呼叫使用 Service Cloud 控制台(这可以防止可能挂断呼叫的浏览器刷新)。

  3. 创建服务云控制台

    -转到设置➤创建➤应用➤新。

    -为应用类型选择“控制台”。

    -为其命名,如“Twilio ACD”

    -接受默认徽标。

    -对于选项卡,向您的服务云控制台添加一些选项卡,如联系人、个案等。

    -接受步骤 5“选择记录显示方式”的默认值

    -将可见性设置为全部(对于开发组织)。

    -您现在已经创建了一个应用!您将在应用下拉列表中看到您的控制台,例如,“Twilio ACD”

  4. 配置屏幕弹出:

    -您可以在设置➤呼叫中心➤(您的呼叫中心)➤软电话布局中配置弹出屏幕响应,例如弹出搜索屏幕。

这些步骤是从 Charles 的原始帖子中借用的,因为它们没有改变。

摘要

现在,您已经有了一个工作的实时呼叫中心 ACD 系统,它可以独立使用(作为一个单独的软电话),也可以在 Salesforce 等 CRM 中使用,或者在完全围绕它构建的 CRM 中使用,这将在第 2 部分中介绍。如果您对最初的客户端 acd 非常熟悉,那么除了在 Node 中重写并使用 Flybase 作为后端/信号系统之外,没有太多变化,这就是本章的计划,因为我想演示如何在呼叫中心中使用 Flybase,这一直是各种项目的首选。

只是提醒一下,你可以在这里找到完整的源代码: https://github.com/flybaseio/callcenter