When a client approached us to build a call-center using the Twilio API we didn't realize how far we would push our "test-driven" philosophy. Join us as we explain how easy it was to go from simply using a library, to regularly running bots to actually dial our app to ensure its integrity.
5. Wanted New Efficiencies
1. Connecting customers to the same agent;
developing a personal relationship.
2. Automatically popping up the customer record
on the agent's browser.
3. Collect call metrics and tie into other
datapoints.
Sunday, December 16, 12
6. Current Provider...
1. Offered little to no real-time integration.
2. Was unable or unwilling to customize solution.
3. Was expensive.
Sunday, December 16, 12
8. Twilio...
1. A REST-ful API to make and manipulate calls
and their associated data.
2. Makes real-time callbacks over HTTP to your
application about incoming and ongoing calls.
3. Inexpensive: $1 per number, $0.01 per call leg
Sunday, December 16, 12
11. Call Flows
1. A user can click-to-call a target; the user's
phone is called first and is then connected to
the target.
2. A user can enter any number and call it; the
user's phone is called first and is then
connected to the number.
3. If a target calls the mainline, they are
immediately connected to a user.
4. Unknown callers to the mainline are placed on
a hold queue; any user can handle them.
Sunday, December 16, 12
14. Productizing Twilio
http://kalzumeus.com/2011/12/19/productizing-twilio-applications/
Sunday, December 16, 12
15. Treat TwiML as Your
View
We use Builder to generate XML
Sunday, December 16, 12
16. TWiML Builder View
xml.instruct!
xml.Response do
xml.Say 'Hello. Are you'
xml.Play 'https://s3.amazonaws.com/CarbonFive/placeholder.wav'
xml.Say 'If not, please hold.'
xml.Play 'https://s3.amazonaws.com/CarbonFive/sign_off.wav'
xml.Enqueue(action: goodbye_twilio_call_path(@call),
waitUrl: hold_twilio_call_path(@call)) do
xml.text! 'hold'
end
end
Sunday, December 16, 12
17. Port-Forward Callbacks
To Development
Set it up yourself or use:
localtunnel http://localtunnel.com
forward http://forwardhq.com
Sunday, December 16, 12
18. Model Calls AND
Conversations
We made heavy use of state_machine gem
https://github.com/pluginaweek/state_machine
Sunday, December 16, 12
19. Where's the
Robopocalypse?
Sunday, December 16, 12
22. Test Account
Like payment providers, it allows you to make
dummy calls, with specific phone numbers
resulting in specific responses.
Sunday, December 16, 12
23. Record responses with
VCR
Speeds up test suite.
https://github.com/myronmarston/vcr
Sunday, December 16, 12
40. We're are NOT testing
Twilio.
Sunday, December 16, 12
41. We are testing WITH
Twilio.
An important distinction!
Sunday, December 16, 12
42. The Pieces to a Solution
were lying around.
Sunday, December 16, 12
43. What does it look like?
Twilio Account
Capybara The App
Browser of the App
Rspec/
Cucumber
Sunday, December 16, 12
44. What does it look like?
Twilio Account
Capybara The App
Browser of the App
Rspec/
Cucumber
Twilio Account
of the Bots
Sunday, December 16, 12
45. What does it look like?
Twilio Account
Capybara The App
Browser of the App
Rspec/
Cucumber
Sinatra
Twilio Account
of the Bots
Sunday, December 16, 12
46. What does it look like?
Twilio Account
Capybara The App
Browser of the App
Rspec/
Cucumber
Sinatra
Twilio Account
of the Bots
Sunday, December 16, 12
47. What does it look like?
Twilio Account
Capybara The App
Browser of the App
Rspec/
Cucumber
Sinatra
Twilio Account
of the Bots
Calls
Sunday, December 16, 12
48. What does it look like?
Twilio Account
Capybara The App
Browser of the App
Rspec/
Cucumber
Sinatra
Twilio Account
of the Bots
Bots Calls
Sunday, December 16, 12
49. What does it look like?
Twilio Account
Capybara The App
Browser of the App
Rspec/
Cucumber
Sinatra
Twilio Account
of the Bots
Bots Calls
Sunday, December 16, 12
50. What does it look like?
Twilio Account
Capybara The App
Browser of the App
Rspec/
Cucumber
Sinatra
Twilio Account
of the Bots
Bots Calls
Twilio Client
Sunday, December 16, 12
51. WOPR
http://github.com/ZestFinance/wopr
http://github.com/carbonfive/wopr
Sunday, December 16, 12
52. A @wopr of a Feature
@javascript @wopr
Feature: Outbound Call
A user can enter phone number so that they can call it
Scenario: Simple Session
Given a user is logged in
And the user enters a phone number
And the user clicks the Call button
Then the user's phone is called
And the phone number is called
And they are speaking to each other
Sunday, December 16, 12
53. Setup in Cucumber
require 'wopr/cucumber'
require File.join(File.dirname(__FILE__), '..', '..', 'staging')
Wopr.configure do |config|
config.twilio_server_port = 4000
config.twilio_callback_host = 'http://rudyjahchan.fwd.wf'
config.twilio_account_sid = TWILIO_ACCOUNT_SID
config.twilio_auth_token = TWILIO_AUTH_TOKEN
end
require File.join(File.dirname(__FILE__), '..', '..', 'bots')
Wopr::TwilioService.new.update_callbacks([
Wopr::Bot[:ahnold].phone_number,
Wopr::Bot[:kyle].phone_number])
Wopr::TwilioCallbackServer.boot
Sunday, December 16, 12
55. The Goal: Simpler Code
Then /^the user's phone is called$/ do
bot(:ahnold).should be_on_a_call
end
Then /^the user's phone is not called$/ do
bot(:ahnold).should_not be_on_a_call
end
Then /^the phone number is called$/ do
bot(:kyle).should be_on_a_call
end
Then /^they are speaking to each other$/ do
bot(:kyle).should be_on_a_call_with(bot(:ahnold))
end
Given /^an unknown caller dials the main line$/ do
bot(:kyle).make_a_call_to(CYBERDYNE_STAGING_PHONE_NUMBER)
end
Sunday, December 16, 12
57. Stole a LOT from
Capybara
Particularly threading code not to block running
specs.
Sunday, December 16, 12
58. Sinatra App
module Wopr
class TwilioCallbackServer < Sinatra::Base
VERIFICATION_PHRASE = 'SHALL WE PLAY A GAME?'
set :views,
File.join(File.dirname(__FILE__), 'templates')
get '/__identify__' do
[200, {}, VERIFICATION_PHRASE]
end
post '/calls' do
# ...
end
# ...
end
end
Sunday, December 16, 12
59. Mount on Rack
def run_server(port)
require 'rack/handler/thin'
Thin::Logging.silent = true
Rack::Handler::Thin.run(self,
Port: port)
rescue LoadError
require 'rack/handler/webrick'
Rack::Handler::WEBrick.run(self,
Port: port,
AccessLog: [],
Logger: WEBrick::Log::new(nil, 0))
end
Sunday, December 16, 12
60. Launch in a thread
def boot(port=Wopr.twilio_server_port)
@port = port
unless responsive?
@server_thread = Thread.new { run_server(@port) }
end
Timeout.timeout(60) { @server_thread.join(0.1) until responsive? }
end
def responsive?
return false if @server_thread && @server_thread.join(0)
res = Net::HTTP.start('127.0.0.1', @port) do |http|
http.get('/__identify__')
end
if res.is_a?(Net::HTTPSuccess) or res.is_a?(Net::HTTPRedirection)
return res.body == VERIFICATION_PHRASE
end
rescue Errno::ECONNREFUSED, Errno::EBADF
return false
end
Sunday, December 16, 12
61. Server manages Calls
post '/calls' do
if(call = Call.find_by_sid(params[:CallSid]))
call.update params
else
Call.create(params)
end
builder :default
end
Sunday, December 16, 12
62. <Say /> Keep-alive
xml.instruct!
xml.Response do
xml.Say(loop: 0) do
xml.text! <<GIBBERISH
Yorn desh born, der ritt de gitt der gue,
Orn desh, dee born desh, de umn
bork! bork! bork!
GIBBERISH
end
end
Sunday, December 16, 12
63. Bots can examine Calls
module Wopr
class Bot
# ...
def current_call
Call.find_all_by_number(phone_number).select{|call|
call.status != 'completed'}.last
end
def on_a_call?
wait_until do
current_call
end
end
# ...
end
end
Sunday, December 16, 12
64. Handle Asynchronicity
def eventually(seconds=Wopr.default_wait_time)
start_time = Time.now
begin
yield
rescue => e
raise e if (Time.now - start_time) >= seconds
sleep 1
retry
end
end
def wait_until(seconds=Wopr.default_wait_time)
eventually(seconds) do
result = yield
return result if result
raise ConditionNotMetError
end
rescue ConditionNotMetError
return false
end
Sunday, December 16, 12
65. Bot makes calls w/ Call
module Wopr
class Bot
# ...
def make_a_call_to(phone_number)
Call.make(from: self.phone_number, to: phone_number)
end
# ...
end
end
module Wopr
class Call
class << self
def make(options)
TwilioService.new.make(options)
end
# ...
Sunday, December 16, 12
66. TwilioClientService
require 'twilio-ruby'
module Wopr
class TwilioService
def initialize
@twilio_client = Twilio::REST::Client.new(
Wopr.twilio_account_sid,
Wopr.twilio_auth_token
)
end
def make(options)
calls.create(options.merge(
url: "#{Wopr.twilio_callback_host}/calls"
))
end
def hangup(sid)
call(sid).hangup
end
# ...
Sunday, December 16, 12
67. Do we KNOW they're
TALKING to each other?
It's possible the system made two phone calls, but
they’re not with each other.
Sunday, December 16, 12
68. A Solution: Play and
Detect Dial Tones!
One bot starts to <Gather> digits, the other
<Plays> them, and we confirm they receive it.
Sunday, December 16, 12
69. Call Gather & Play
module Wopr
class Call
# ...
def play(digits)
TwilioService.new.play sid, digits
end
def gather
TwilioService.new.gather sid
end
# ...
end
end
Sunday, December 16, 12
70. Redirect Calls to TWiML
module Wopr
class TwilioService
# ...
def play(sid, digits)
call(sid).redirect_to(
"#{Wopr.twilio_callback_host}/calls/#{sid}/play?digits=#{digits}"
)
end
def gather(sid)
call(sid).redirect_to(
"#{Wopr.twilio_callback_host}/calls/#{sid}/gather"
)
end
# ...
end
end
Sunday, December 16, 12
71. Prepare to <Gather />
module Wopr
class TwilioCallbackServer < Sinatra::Base
# ...
post '/calls/:sid/gather' do
builder :gather, locals: { sid: params[:sid] }
end
# ...
end
end
gather.builder
xml.instruct!
xml.Response do
xml.Gather(
timeout: "60",
action: "#{Wopr.twilio_callback_host}/calls/#{sid}/gathered",
numDigits: "4")
end
Sunday, December 16, 12
72. <Play digits="..." />
module Wopr
class TwilioCallbackServer < Sinatra::Base
# ...
post '/calls/:sid/play' do
builder :play, locals: { sid: params[:sid], digits:
params[:digits] }
end
# ...
end
end
play.builder
xml.instruct!
xml.Response do
xml.Play(digits: digits)
xml.Pause(length: 10)
end
Sunday, December 16, 12
73. Gathered Digits Posted
module Wopr
class TwilioCallbackServer < Sinatra::Base
# ...
post '/calls/:sid/gathered' do
if(call = Call.find_by_sid(params[:sid]))
call.gathered params[:Digits]
end
builder :default
end
# ...
end
end
Sunday, December 16, 12
74. Again Asynchronous!
module Wopr
class Bot
# ...
def on_a_call_with?(another_bot)
current_call.gather
sleep 1
another_bot.current_call.play '6661'
wait_until do
current_call.gathered_digits.last == '6661'
end
end
# ...
end
end
Sunday, December 16, 12
75. Dial-Tone not a Perfect
Solution.
What if tones are used to trigger actions? And
how do we confirm audio FILE playback?
Sunday, December 16, 12
76. <Record> and SOX
Retrieve the recording, digest with SOX audio
library.
Sunday, December 16, 12
77. More Capybara Tie-ins?
Given /^a user is logged in$/ do
bot(:ahnold).log_in
end
Given /^the user enters a phone number$/ do
bot(:ahnold).within('div#call') do
fill_in 'number', with: bot(:kyle).phone_number
end
end
Sunday, December 16, 12
78. How far can we take it?
Sunday, December 16, 12