how test driven development started the robot apocalypse; lessons learned using twilio for telephony

Post on 19-May-2015

6.545 Views

Category:

Technology

0 Downloads

Preview:

Click to see full reader

DESCRIPTION

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.

TRANSCRIPT

Sunday, December 16, 12

Lessons Learned Using and Testing with TwilioTips, Tricks, and Best Practices

Sunday, December 16, 12

How Test Driven Design started the Robot Apocalypse!... And how it's not my fault!

Sunday, December 16, 12

Customer Call System

Sunday, December 16, 12

Wanted New Efficiencies1. 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

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

Sunday, December 16, 12

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

[censored client]

Sunday, December 16, 12

Sunday, December 16, 12

Call Flows1. 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

Demo

Sunday, December 16, 12

Excellent Documentationhttps://twilio.com/docs

Sunday, December 16, 12

Productizing Twiliohttp://kalzumeus.com/2011/12/19/productizing-twilio-applications/

Sunday, December 16, 12

Treat TwiML as Your ViewWe use Builder to generate XML

Sunday, December 16, 12

TWiML Builder Viewxml.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' endend

Sunday, December 16, 12

Port-Forward Callbacks To DevelopmentSet it up yourself or use:localtunnel http://localtunnel.comforward http://forwardhq.com

Sunday, December 16, 12

Model Calls AND ConversationsWe made heavy use of state_machine gemhttps://github.com/pluginaweek/state_machine

Sunday, December 16, 12

Where's the Robopocalypse?

Sunday, December 16, 12

Testing

Sunday, December 16, 12

Deprecated Sandbox

Sunday, December 16, 12

Test AccountLike payment providers, it allows you to make dummy calls, with specific phone numbers resulting in specific responses.

Sunday, December 16, 12

Record responses with VCRSpeeds up test suite. https://github.com/myronmarston/vcr

Sunday, December 16, 12

Conversations make for Messy State Machines

Sunday, December 16, 12

Sunday, December 16, 12

Sunday, December 16, 12

On top of all that, we were still figuring it out!

Sunday, December 16, 12

Changes would blow away functionality!

Sunday, December 16, 12

Testing becameManual LaborOur poor PM constantly clicking through scenarios.

Sunday, December 16, 12

PhonioMade use of Twilio JS Client to provide multiple numbers backed by another Twilio account.

Sunday, December 16, 12

How could we automate this?As part of the build and continuous integration.

Sunday, December 16, 12

Sunday, December 16, 12

I didn't know

Sunday, December 16, 12

Gaming BotsScripts that would act as other players to in networked games.

Sunday, December 16, 12

CapybaraRSpec and Cucumber features use it to script a user going through your application.

Sunday, December 16, 12

Remote HostCapybara.run_server = falseCapybara.app_host = 'http://staging.cyberdyne.com'

Alternativelyrequire 'capybara/cucumber'require 'capybara/spec/test_app'

Capybara.app = TestAppCapybara.app_host = 'http://staging.cyberdyne.com'

Sunday, December 16, 12

Supports Multi-"Users"using_session :ahnold do visit '/signin' click 'Terminate', within: '#sarah_connor'end

using_session :robert do visit '/sightings/new' check 'have_you_seen_this_boy' click 'Submit'end

Sunday, December 16, 12

How to do the same for phones?

Sunday, December 16, 12

Sunday, December 16, 12

We're are NOT testing Twilio.

Sunday, December 16, 12

We are testing WITH Twilio.An important distinction!

Sunday, December 16, 12

The Pieces to a Solution were lying around.

Sunday, December 16, 12

What does it look like?

Rspec/Cucumber

CapybaraBrowser

The AppTwilio Account

of the App

Sunday, December 16, 12

What does it look like?

Rspec/Cucumber

CapybaraBrowser

The AppTwilio Account

of the App

Twilio Accountof the Bots

Sunday, December 16, 12

What does it look like?

Rspec/Cucumber

CapybaraBrowser

The AppTwilio Account

of the App

Twilio Accountof the Bots

Sinatra

Sunday, December 16, 12

What does it look like?

Rspec/Cucumber

CapybaraBrowser

The AppTwilio Account

of the App

Twilio Accountof the Bots

Sinatra

Sunday, December 16, 12

What does it look like?

Rspec/Cucumber

CapybaraBrowser

The AppTwilio Account

of the App

Twilio Accountof the Bots

Calls

Sinatra

Sunday, December 16, 12

What does it look like?

Rspec/Cucumber

CapybaraBrowser

The AppTwilio Account

of the App

Twilio Accountof the Bots

CallsBots

Sinatra

Sunday, December 16, 12

What does it look like?

Rspec/Cucumber

CapybaraBrowser

The AppTwilio Account

of the App

Twilio Accountof the Bots

CallsBots

Sinatra

Sunday, December 16, 12

What does it look like?

Twilio Client

Rspec/Cucumber

CapybaraBrowser

The AppTwilio Account

of the App

Twilio Accountof the Bots

CallsBots

Sinatra

Sunday, December 16, 12

WOPRhttp://github.com/ZestFinance/woprhttp://github.com/carbonfive/wopr

Sunday, December 16, 12

A @wopr of a Feature@javascript @woprFeature: 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

Setup in Cucumberrequire '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_TOKENend

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

Creat identified BotsWopr::Bot.create(:ahnold, email: 'rudy+ahnold@carbonfive.com', password: 'n07@r3@1p@$$w0rd', phone_number: '5558675309')

Wopr::Bot.create(:kyle, phone_number: '5557779311')

Wopr::Bot.create(:sarah, phone_number: '9006492568')

Sunday, December 16, 12

The Goal: Simpler CodeThen /^the user's phone is called$/ do bot(:ahnold).should be_on_a_callend

Then /^the user's phone is not called$/ do bot(:ahnold).should_not be_on_a_callend

Then /^the phone number is called$/ do bot(:kyle).should be_on_a_callend

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

How does it work?

Sunday, December 16, 12

Stole a LOT from CapybaraParticularly threading code not to block running specs.

Sunday, December 16, 12

Sinatra Appmodule 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

# ...

endend

Sunday, December 16, 12

Mount on Rackdef 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

Launch in a threaddef 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 endrescue Errno::ECONNREFUSED, Errno::EBADF return falseend

Sunday, December 16, 12

Server manages Callspost '/calls' do if(call = Call.find_by_sid(params[:CallSid])) call.update params else Call.create(params) end

builder :defaultend

Sunday, December 16, 12

<Say /> Keep-alivexml.instruct!xml.Response do xml.Say(loop: 0) do xml.text! <<GIBBERISHYorn desh born, der ritt de gitt der gue, Orn desh, dee born desh, de umn bork! bork! bork!GIBBERISH endend

Sunday, December 16, 12

Bots can examine Callsmodule 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

# ...

endend

Sunday, December 16, 12

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

Bot makes calls w/ Callmodule Wopr class Bot

# ...

def make_a_call_to(phone_number) Call.make(from: self.phone_number, to: phone_number) end

# ... endend

module Wopr class Call class << self def make(options) TwilioService.new.make(options)

end

# ...

Sunday, December 16, 12

TwilioClientServicerequire '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

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

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

Call Gather & Playmodule Wopr class Call

# ...

def play(digits) TwilioService.new.play sid, digits end

def gather TwilioService.new.gather sid end

# ...

endend

Sunday, December 16, 12

Redirect Calls to TWiMLmodule 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

# ...

endend

Sunday, December 16, 12

Prepare to <Gather />

xml.instruct!xml.Response do xml.Gather( timeout: "60", action: "#{Wopr.twilio_callback_host}/calls/#{sid}/gathered", numDigits: "4")end

module Wopr class TwilioCallbackServer < Sinatra::Base

# ...

post '/calls/:sid/gather' do builder :gather, locals: { sid: params[:sid] } end

# ... endend

gather.builder

Sunday, December 16, 12

<Play digits="..." />module Wopr class TwilioCallbackServer < Sinatra::Base

# ...

post '/calls/:sid/play' do builder :play, locals: { sid: params[:sid], digits: params[:digits] } end

# ... endend

xml.instruct!xml.Response do xml.Play(digits: digits) xml.Pause(length: 10)end

play.builder

Sunday, December 16, 12

Gathered Digits Postedmodule 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

# ...

endend

Sunday, December 16, 12

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

# ...

endend

Sunday, December 16, 12

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

<Record> and SOXRetrieve the recording, digest with SOX audio library.

Sunday, December 16, 12

More Capybara Tie-ins?Given /^a user is logged in$/ do bot(:ahnold).log_inend

Given /^the user enters a phone number$/ do bot(:ahnold).within('div#call') do fill_in 'number', with: bot(:kyle).phone_number endend

Sunday, December 16, 12

How far can we take it?

Sunday, December 16, 12

[pic]

Sunday, December 16, 12

Sample Use of woprhttp://github.com/carbonfive/cyberdyne-systems

Sunday, December 16, 12

Questions?rudy@carbonfive.com@rudy on Twitter

Sunday, December 16, 12

Hasta la vista, baby!

Sunday, December 16, 12

top related