├── .ruby-version ├── .gitignore ├── .rspec ├── .env ├── Procfile ├── screenshots ├── state-example.png ├── balance-screenshot.png └── state-pull-example.png ├── spec ├── rack_spec_helpers.rb ├── phone_number_processor_spec_helper.rb ├── support │ └── fone_number_processor.rb ├── dollar_amounts_processor_spec_helper.rb ├── app_spec_helper.rb ├── lib │ ├── balance_log_analyzer_spec.rb │ ├── phone_number_processor_spec.rb │ ├── message_generator_spec.rb │ ├── dollar_amounts_processor_spec.rb │ └── state_handler_spec.rb ├── fixtures │ └── vcr_cassettes │ │ ├── messages-for-status-check-system-down.yml │ │ ├── messages-for-status-check-system-working.yml │ │ └── phone_number_api_call.yml ├── spec_helper.rb └── app_spec.rb ├── config.ru ├── .travis.yml ├── twilio_console.rb ├── lib ├── twilio_service.rb ├── transcription_parsing_helpers.rb ├── state_handler.rb ├── state_handler │ ├── base.rb │ ├── tx.rb │ ├── ca.rb │ ├── ok.rb │ ├── nc.rb │ ├── example.rb │ ├── va.rb │ ├── ak.rb │ ├── pa.rb │ └── mo.rb ├── phone_number_processor.rb ├── dollar_amounts_processor.rb ├── balance_log_analyzer.rb └── message_generator.rb ├── CONTRIBUTING.md ├── config ├── unicorn.rb └── newrelic.yml ├── Gemfile ├── LICENSE.md ├── README.md ├── Gemfile.lock ├── app.rb └── HOW_TO_ADD_A_STATE.md /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.2.4 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | old/ 2 | .env* -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | TWILIO_SID=mytwiliosid 2 | TWILIO_AUTH=mytwilioauth 3 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: bundle exec unicorn -p $PORT -c ./config/unicorn.rb 2 | -------------------------------------------------------------------------------- /screenshots/state-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeforamerica/balance/master/screenshots/state-example.png -------------------------------------------------------------------------------- /spec/rack_spec_helpers.rb: -------------------------------------------------------------------------------- 1 | module RackSpecHelpers 2 | include Rack::Test::Methods 3 | attr_accessor :app 4 | end 5 | -------------------------------------------------------------------------------- /screenshots/balance-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeforamerica/balance/master/screenshots/balance-screenshot.png -------------------------------------------------------------------------------- /screenshots/state-pull-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeforamerica/balance/master/screenshots/state-pull-example.png -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | require './app' 2 | require 'rack-timeout' 3 | Rack::Timeout.timeout = 10 4 | $stdout.sync = true 5 | run EbtBalanceSmsApp 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - "2.2.4" 4 | before_install: gem install foreman 5 | script: foreman run bundle exec rspec spec 6 | notifications: 7 | webhooks: http://project-monitor.codeforamerica.org/projects/418a62a0-e994-432d-9df5-0b398504c48f/status 8 | -------------------------------------------------------------------------------- /twilio_console.rb: -------------------------------------------------------------------------------- 1 | require 'twilio-ruby' 2 | require 'pry' 3 | 4 | staging_client = Twilio::REST::Client.new(ENV['TWILIO_BALANCE_STAGING_SID'], ENV['TWILIO_BALANCE_STAGING_AUTH']) 5 | production_client = Twilio::REST::Client.new(ENV['TWILIO_BALANCE_PROD_SID'], ENV['TWILIO_BALANCE_PROD_AUTH']) 6 | 7 | binding.pry 8 | 9 | -------------------------------------------------------------------------------- /lib/twilio_service.rb: -------------------------------------------------------------------------------- 1 | class TwilioService 2 | attr_reader :client 3 | 4 | def initialize(twilio_client) 5 | @client = twilio_client 6 | end 7 | 8 | def make_call(params) 9 | @client.account.calls.create(params) 10 | end 11 | 12 | def send_text(params) 13 | @client.account.messages.create(params) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/phone_number_processor_spec_helper.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../spec_helper', __FILE__) 2 | require File.expand_path('../../lib/phone_number_processor', __FILE__) 3 | require 'twilio-ruby' 4 | require 'vcr' 5 | 6 | VCR.configure do |c| 7 | c.cassette_library_dir = 'spec/fixtures/vcr_cassettes' 8 | c.hook_into :webmock 9 | end 10 | -------------------------------------------------------------------------------- /lib/transcription_parsing_helpers.rb: -------------------------------------------------------------------------------- 1 | module TranscriptionParsingHelpers 2 | def clean_trailing_period(amount_string) 3 | if amount_string[-1] == '.' 4 | amount_string[0..-2] 5 | else 6 | amount_string 7 | end 8 | end 9 | 10 | def process_transcription_for_zero_text(text) 11 | text.gsub("zero dollars", "$0") 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Due to the nuances of Twilio, all pull requests should be manually tested on Staging prior to merging. Specifically: 2 | 3 | 1. Text bad input (eg, "lol") to confirm error response 4 | 5 | 2. Text our valid EBT number to confirm (a) 1-2 min wait message, and (b) successful balance check 6 | 7 | 3. Call Staging to confirm you receive a text 8 | 9 | Once this is done, please comment on your PR stating that you've successfully tested behavior for your PR on staging. 10 | 11 | -------------------------------------------------------------------------------- /config/unicorn.rb: -------------------------------------------------------------------------------- 1 | worker_processes Integer(ENV["WEB_CONCURRENCY"] || 3) 2 | timeout 15 3 | preload_app true 4 | 5 | before_fork do |server, worker| 6 | Signal.trap 'TERM' do 7 | puts 'Unicorn master intercepting TERM and sending myself QUIT instead' 8 | Process.kill 'QUIT', Process.pid 9 | end 10 | end 11 | 12 | after_fork do |server, worker| 13 | Signal.trap 'TERM' do 14 | puts 'Unicorn worker intercepting TERM and doing nothing. Wait for master to send QUIT' 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/support/fone_number_processor.rb: -------------------------------------------------------------------------------- 1 | # Fake of lib/phone_number_processor for use in testing web app 2 | # without hitting Twilio 3 | 4 | class FoneNumberProcessor 5 | def initialize 6 | @language_hash = { '+15556667777' => :english, '+19998887777' => :spanish } 7 | end 8 | 9 | def twilio_number?(phone_number) 10 | @language_hash.keys.include?(phone_number) 11 | end 12 | 13 | def language_for(phone_number) 14 | @language_hash[phone_number] or :english 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | ruby '2.2.4' 3 | 4 | gem 'sinatra' 5 | gem 'sinatra-contrib' 6 | gem 'twilio-ruby' 7 | gem 'rack-ssl' 8 | gem 'require_all' 9 | gem 'unicorn' 10 | gem 'rack-timeout' 11 | gem 'newrelic_rpm' 12 | gem 'activesupport' 13 | 14 | # For DollarAmountsProcessor 15 | gem 'numbers_in_words' 16 | 17 | group :test, :development do 18 | gem 'rspec' 19 | gem 'rack-test' 20 | gem 'pry' 21 | gem 'dotenv' 22 | gem 'nokogiri' 23 | gem 'foreman' 24 | gem 'vcr' 25 | gem 'webmock' 26 | gem 'timecop' 27 | end 28 | -------------------------------------------------------------------------------- /lib/state_handler.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../message_generator', __FILE__) 2 | require File.expand_path('../transcription_parsing_helpers', __FILE__) 3 | require File.expand_path('../dollar_amounts_processor', __FILE__) 4 | 5 | module StateHandler 6 | extend self 7 | 8 | def for(state_abbreviation) 9 | if handled_states.include?(state_abbreviation.to_sym) 10 | eval("StateHandler::#{state_abbreviation}.new") 11 | else 12 | StateHandler::UnhandledState.new #StateHandler::CA by default, likely 13 | end 14 | end 15 | 16 | def handled_states 17 | constants 18 | end 19 | end 20 | 21 | require 'require_all' 22 | require_all File.expand_path('../state_handler', __FILE__) 23 | 24 | class StateHandler::UnhandledState < StateHandler::CA 25 | end 26 | -------------------------------------------------------------------------------- /spec/dollar_amounts_processor_spec_helper.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../spec_helper', __FILE__) 2 | require File.expand_path('../../lib/dollar_amounts_processor', __FILE__) 3 | 4 | RSpec::Matchers.define :eq_text do |expected| 5 | def compr(text) 6 | text.gsub(/[\s\n\r]+/s,' ') 7 | end 8 | match do |actual| 9 | @expected = compr(expected) 10 | @actual = compr(actual) 11 | @expected == @actual 12 | end 13 | failure_message do 14 | "\nexpected: #{expected_formatted}\n got: #{actual_formatted}\n\n(compared using ==)\n" 15 | end 16 | 17 | def expected_formatted 18 | RSpec::Support::ObjectFormatter.format(@expected) 19 | end 20 | 21 | # @private 22 | def actual_formatted 23 | RSpec::Support::ObjectFormatter.format(@actual) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/app_spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'rack/test' 3 | require 'nokogiri' 4 | require 'sinatra' 5 | require 'vcr' 6 | require 'timecop' 7 | require File.expand_path('../rack_spec_helpers', __FILE__) 8 | 9 | class EbtBalanceSmsApp < Sinatra::Base 10 | set :environment, :test 11 | end 12 | 13 | VCR.configure do |config| 14 | config.cassette_library_dir = "spec/fixtures/vcr_cassettes" 15 | config.hook_into :webmock 16 | end 17 | 18 | RSpec.configure do |config| 19 | config.include RackSpecHelpers 20 | config.before(:example, :type => :feature) do 21 | require File.expand_path('../../lib/phone_number_processor', __FILE__) 22 | require File.expand_path('../support/fone_number_processor', __FILE__) 23 | allow(PhoneNumberProcessor).to receive(:new).and_return(FoneNumberProcessor.new) 24 | require File.expand_path('../../app', __FILE__) 25 | self.app = EbtBalanceSmsApp 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/state_handler/base.rb: -------------------------------------------------------------------------------- 1 | class StateHandler::Base 2 | include TranscriptionParsingHelpers 3 | 4 | def phone_number 5 | self.class.const_get(:PHONE_NUMBER) 6 | end 7 | 8 | def allowed_number_of_ebt_card_digits 9 | self.class.const_get(:ALLOWED_NUMBER_OF_EBT_CARD_DIGITS) 10 | end 11 | 12 | def extract_valid_ebt_number_from_text(text) 13 | whitespace_free_text = text.gsub(" ", "") 14 | dash_and_whitespace_free_text = whitespace_free_text.gsub("-", "") 15 | number_matches = dash_and_whitespace_free_text.match(/\d+/) 16 | number = number_matches.to_s 17 | if allowed_number_of_ebt_card_digits.include?(number.length) && number.match(/\D+/) == nil 18 | return number 19 | else 20 | return :invalid_number 21 | end 22 | end 23 | 24 | def transcribe_balance_response(transcription_text, language = :english) 25 | transcription_text 26 | end 27 | 28 | def max_message_length 29 | 18 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/phone_number_processor.rb: -------------------------------------------------------------------------------- 1 | require 'twilio-ruby' 2 | 3 | class PhoneNumberProcessor 4 | SUPPORTED_LANGUAGES = %w(spanish) 5 | attr_reader :lookup_hash 6 | 7 | def initialize 8 | @lookup_hash = Hash.new 9 | phone_number_list = Twilio::REST::Client.new(ENV['TWILIO_SID'], ENV['TWILIO_AUTH']).account.incoming_phone_numbers.list 10 | phone_number_list.each do |pn| 11 | SUPPORTED_LANGUAGES.each do |language| 12 | if pn.friendly_name.include?(language.to_s) 13 | @lookup_hash[pn.phone_number] = language.to_sym 14 | else 15 | @lookup_hash[pn.phone_number] = :english 16 | end 17 | end 18 | end 19 | end 20 | 21 | def twilio_number?(number) 22 | lookup_hash.keys.include?(number) 23 | end 24 | 25 | def language_for(phone_number) 26 | if phone_number.include?('+') == false 27 | phone_number_with_plus = '+' + phone_number 28 | lookup_hash[phone_number_with_plus] 29 | else 30 | lookup_hash[phone_number] 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/state_handler/tx.rb: -------------------------------------------------------------------------------- 1 | class StateHandler::TX < StateHandler::Base 2 | PHONE_NUMBER = '+18007777328' 3 | ALLOWED_NUMBER_OF_EBT_CARD_DIGITS = [19] 4 | 5 | def button_sequence(ebt_number) 6 | "wwww1wwwwww#{ebt_number}wwww" 7 | end 8 | 9 | def transcribe_balance_response(transcription_text, language = :english) 10 | mg = MessageGenerator.new(language) 11 | if transcription_text == nil 12 | return mg.having_trouble_try_again_message 13 | end 14 | text_with_dollar_amounts = DollarAmountsProcessor.new.process(transcription_text) 15 | regex_matches = text_with_dollar_amounts.scan(/(\$\S+)/) 16 | if transcription_text.include?("please enter the") 17 | mg.card_number_not_found_message 18 | elsif regex_matches.count > 0 19 | ebt_amount = clean_trailing_period(regex_matches[0][0]) 20 | if ebt_amount.match(/(\d{5,10})/) 21 | ebt_amount.gsub!("0","") 22 | end 23 | return mg.balance_message(ebt_amount) 24 | else 25 | mg.having_trouble_try_again_message 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Code for America 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/state_handler/ca.rb: -------------------------------------------------------------------------------- 1 | class StateHandler::CA < StateHandler::Base 2 | PHONE_NUMBER = '+18773289677' 3 | ALLOWED_NUMBER_OF_EBT_CARD_DIGITS = [16] 4 | 5 | def button_sequence(ebt_number) 6 | waiting_ebt_number = ebt_number.split('').join('ww') 7 | "wwwwwwwwwwwwwwww1wwwwwwwwwwwwww#{waiting_ebt_number}ww#wwww" 8 | end 9 | 10 | def transcribe_balance_response(transcription_text, language = :english) 11 | mg = MessageGenerator.new(language) 12 | if transcription_text == nil 13 | return mg.having_trouble_try_again_message 14 | end 15 | text_with_dollar_amounts = DollarAmountsProcessor.new.process(transcription_text) 16 | processed_transcription = process_transcription_for_zero_text(text_with_dollar_amounts) 17 | puts processed_transcription 18 | regex_matches = processed_transcription.scan(/(\$\S+)/) 19 | if processed_transcription.include?("non working card") 20 | mg.card_number_not_found_message 21 | elsif regex_matches.count > 0 22 | ebt_amount = clean_trailing_period(regex_matches[0][0]) 23 | # for now omit other balances since now includes future 24 | return mg.balance_message(ebt_amount) 25 | else 26 | mg.having_trouble_try_again_message 27 | end 28 | end 29 | 30 | def max_message_length 31 | 22 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/state_handler/ok.rb: -------------------------------------------------------------------------------- 1 | class StateHandler::OK < StateHandler::Base 2 | PHONE_NUMBER = '+18883286551' 3 | ALLOWED_NUMBER_OF_EBT_CARD_DIGITS = [16] 4 | 5 | def button_sequence(ebt_number) 6 | "11#{ebt_number}" 7 | end 8 | 9 | def transcribe_balance_response(transcription_text, language = :english) 10 | mg = MessageGenerator.new(language) 11 | 12 | # Deal with a failed transcription 13 | if transcription_text == nil 14 | return mg.having_trouble_try_again_message 15 | end 16 | 17 | # Deal with an invalid card number 18 | phrase_indicating_invalid_card_number = "please try again" 19 | 20 | if transcription_text.include?(phrase_indicating_invalid_card_number) 21 | return mg.card_number_not_found_message 22 | end 23 | 24 | # Deal with a successful balance transcription 25 | text_with_dollar_amounts = DollarAmountsProcessor.new.process(transcription_text) 26 | regex_matches = text_with_dollar_amounts.scan(/(\$\d+\.?\d*)/) 27 | 28 | if regex_matches.count == 1 29 | ebt_amount = regex_matches[0][0]+"" 30 | return mg.balance_message(ebt_amount) 31 | end 32 | 33 | # Deal with any other transcription (catching weird errors) 34 | # You do not need to change this. :D 35 | return mg.having_trouble_try_again_message 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Balance 2 | 3 | [![Build Status](https://travis-ci.org/codeforamerica/balance.svg?branch=master)](https://travis-ci.org/codeforamerica/balance) 4 | 5 | A text message interface for people to check their EBT card balance for SNAP and other human service benefits 6 | 7 | ![Alt text](screenshots/balance-screenshot.png) 8 | 9 | Currently unavailable. This project is no longer maintained. 10 | 11 | - California 12 | - Texas 13 | - Pennsylvania 14 | - Alaska 15 | - Virginia 16 | - Oklahoma 17 | - North Carolina 18 | 19 | ## What it is 20 | 21 | This is a simple Ruby app built on Twilio that creates a text message interface for people to check their food stamp EBT card balance (and cash balance for other programs). 22 | 23 | The original idea was by @lippytak with influence from @alanjosephwilliams's experience on Code for America's [health project ideas](https://github.com/codeforamerica/health-project-ideas/issues/34) repo. 24 | 25 | This is a project of CFA's Health Lab Team. 26 | 27 | ## Running tests 28 | 29 | Because we use `.env` for testing, you'll want to run your tests by running: 30 | 31 | ``` 32 | foreman run bundle exec rspec spec 33 | ``` 34 | 35 | ## Twilio Console 36 | 37 | The `twilio_console.rb` file just gets you a quick Ruby prompt with Twilio clients pre-loaded. This is useful for doing manual responses to users. To use this, you will need to set the environment variables specified in that file. 38 | 39 | ## Copyright & License 40 | 41 | Copyright Code for America Labs, 2014 — MIT License 42 | -------------------------------------------------------------------------------- /lib/dollar_amounts_processor.rb: -------------------------------------------------------------------------------- 1 | require 'numbers_in_words' 2 | 3 | class DollarAmountsProcessor 4 | 5 | TwilioEnglish = NumbersInWords::English.dup 6 | TwilioEnglish.module_eval do 7 | def self.canonize(w) 8 | w = NumbersInWords::English.canonize(w) 9 | aliases = { 10 | "boy" => "forty" 11 | } 12 | canon = aliases[w] 13 | return canon ? canon : w 14 | end 15 | end 16 | 17 | Words = TwilioEnglish.exceptions_to_i.keys + 18 | TwilioEnglish.powers_of_ten_to_i.keys + 19 | # NumbersInWords does weird things with digits, can not specify in the language 20 | (0..9).map(&:to_s) + 21 | # aliases in canonize are not exposed 22 | ['oh', 'boy'] 23 | MatchDigit = /\b(#{TwilioEnglish.exceptions.keys.join('|')})\b/i 24 | MatchWord = /\b(?:#{Words.join('|')})\b/i 25 | MatchAmountWithCent = /((?:#{MatchWord}\s+)+)\s*dollars?(?:\.? and)?(?:((?:\s+#{MatchWord}){1,2})\s+cents?)?/si 26 | InvalidZeros = /the\s+(row|euro)\.?\s*dollars/si 27 | 28 | def process(text_with_words) 29 | text_with_words.gsub(InvalidZeros, 'zero dollars').gsub(MatchAmountWithCent) do |amount| 30 | m = Regexp.last_match 31 | dollars = in_numbers(m[1]) 32 | dollars += 0.01 * in_numbers(m[2]) if m[2] 33 | '$%.2f' % [ dollars ] 34 | end 35 | end 36 | 37 | def digits_to_words(text_with_digits) 38 | text_with_digits.gsub(MatchDigit) do |digit| 39 | NumbersInWords::English.exceptions[digit.to_i] 40 | end 41 | end 42 | 43 | def in_numbers(text) 44 | NumbersInWords.in_numbers(digits_to_words(text), TwilioEnglish) 45 | end 46 | 47 | end 48 | -------------------------------------------------------------------------------- /lib/state_handler/nc.rb: -------------------------------------------------------------------------------- 1 | # Step 1. Change "::Example" below to a state abbreviation 2 | # For example, "::PA" for Pennsylvania 3 | class StateHandler::NC < StateHandler::Base 4 | 5 | # Step 2. EXAMPLE — Edit for your state! 6 | PHONE_NUMBER = '+18886227328' 7 | 8 | # Step 3. EXAMPLE — Edit for your state! 9 | ALLOWED_NUMBER_OF_EBT_CARD_DIGITS = [16] 10 | 11 | def button_sequence(ebt_number) 12 | # Step 4. EXAMPLE — Edit for your state! 13 | "wwww1wwwwww#{ebt_number}ww" 14 | end 15 | 16 | def transcribe_balance_response(transcription_text, language = :english) 17 | mg = MessageGenerator.new(language) 18 | 19 | # Deal with a failed transcription 20 | # You do not need to change this. :D 21 | if transcription_text == nil 22 | return mg.having_trouble_try_again_message 23 | end 24 | 25 | # Deal with an invalid card number 26 | if transcription_text.include?("re enter") || transcription_text.include?("reenter") 27 | return mg.card_number_not_found_message 28 | end 29 | 30 | # Deal with a successful balance transcription 31 | ### Step 6. EXAMPLE — Edit for your state! ### 32 | text_with_dollar_amounts = DollarAmountsProcessor.new.process(transcription_text) 33 | regex_matches = text_with_dollar_amounts.scan(/(\$\S+)/) 34 | if regex_matches.count == 1 35 | ebt_amount = regex_matches[0][0] 36 | return "Hi! Your food and nutrition benefits balance is #{ebt_amount}." 37 | end 38 | 39 | puts "[DEBUG] #{transcription_text.inspect}" 40 | 41 | # Deal with any other transcription (catching weird errors) 42 | # You do not need to change this. :D 43 | return mg.having_trouble_try_again_message 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/state_handler/example.rb: -------------------------------------------------------------------------------- 1 | # Step 1. Change "::Example" below to a state abbreviation 2 | # For example, "::PA" for Pennsylvania 3 | class StateHandler::Example < StateHandler::Base 4 | 5 | # Step 2. EXAMPLE — Edit for your state! 6 | PHONE_NUMBER = '+1222333444' 7 | 8 | # Step 3. EXAMPLE — Edit for your state! 9 | ALLOWED_NUMBER_OF_EBT_CARD_DIGITS = [16] 10 | 11 | def button_sequence(ebt_number) 12 | # Step 4. EXAMPLE — Edit for your state! 13 | "wwww1wwww#{ebt_number}ww" 14 | end 15 | 16 | =begin # Delete this line when ready to transcribe! 17 | def transcribe_balance_response(transcription_text, language = :english) 18 | mg = MessageGenerator.new(language) 19 | 20 | # Deal with a failed transcription 21 | # You do not need to change this. :D 22 | if transcription_text == nil 23 | return mg.having_trouble_try_again_message 24 | end 25 | 26 | # Deal with an invalid card number 27 | ### Step 5. EXAMPLE — Edit for your state! ### 28 | phrase_indicating_invalid_card_number = "CHANGE ME" 29 | 30 | if transcription_text.include?(phrase_indicating_invalid_card_number) 31 | return mg.card_number_not_found_message 32 | end 33 | 34 | # Deal with a successful balance transcription 35 | ### Step 6. EXAMPLE — Edit for your state! ### 36 | regex_matches = transcription_text.scan(/(\$\S+)/) 37 | if regex_matches.count > 0 38 | ebt_amount = regex_matches[0][0] 39 | return "Hi! Your food stamp balance is #{ebt_amount}." 40 | end 41 | 42 | # Deal with any other transcription (catching weird errors) 43 | # You do not need to change this. :D 44 | return mg.having_trouble_try_again_message 45 | end 46 | =end # Delete this line when ready to transcribe 47 | end 48 | -------------------------------------------------------------------------------- /lib/state_handler/va.rb: -------------------------------------------------------------------------------- 1 | # Step 1. Change "::Example" below to a state abbreviation 2 | # For example, "::PA" for Pennsylvania 3 | class StateHandler::VA < StateHandler::Base 4 | 5 | # Step 2. EXAMPLE — Edit for your state! 6 | PHONE_NUMBER = '+18662812448' 7 | 8 | # Step 3. EXAMPLE — Edit for your state! 9 | ALLOWED_NUMBER_OF_EBT_CARD_DIGITS = [16] 10 | 11 | def button_sequence(ebt_number) 12 | # Step 4. EXAMPLE — Edit for your state! 13 | "wwww1wwww#{ebt_number}ww" 14 | end 15 | 16 | def transcribe_balance_response(transcription_text, language = :english) 17 | mg = MessageGenerator.new(language) 18 | 19 | # Deal with a failed transcription 20 | # You do not need to change this. :D 21 | if transcription_text == nil 22 | return mg.having_trouble_try_again_message 23 | end 24 | 25 | # Deal with an invalid card number 26 | ### Step 5. EXAMPLE — Edit for your state! ### 27 | phrase_indicating_invalid_card_number = "invalid card number" 28 | 29 | if transcription_text.downcase.include?(phrase_indicating_invalid_card_number) 30 | return mg.card_number_not_found_message 31 | end 32 | 33 | # Deal with a successful balance transcription 34 | ### Step 6. EXAMPLE — Edit for your state! ### 35 | text_with_dollar_amounts = DollarAmountsProcessor.new.process(transcription_text) 36 | regex_matches = text_with_dollar_amounts.scan(/(\$\S+)/) 37 | if regex_matches.count > 0 38 | ebt_amount = clean_trailing_period(regex_matches[0][0]) 39 | return mg.balance_message(ebt_amount) 40 | end 41 | 42 | # Deal with any other transcription (catching weird errors) 43 | # You do not need to change this. :D 44 | return mg.having_trouble_try_again_message 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/state_handler/ak.rb: -------------------------------------------------------------------------------- 1 | # Step 1. Change "::Example" below to a state abbreviation 2 | # For example, "::PA" for Pennsylvania 3 | class StateHandler::AK < StateHandler::Base 4 | 5 | # Step 2. EXAMPLE — Edit for your state! 6 | PHONE_NUMBER = '18889978111' 7 | 8 | # Step 3. EXAMPLE — Edit for your state! 9 | ALLOWED_NUMBER_OF_EBT_CARD_DIGITS = [16] 10 | 11 | def button_sequence(ebt_number) 12 | # Step 4. EXAMPLE — Edit for your state! 13 | "wwwwwwww1wwww#{ebt_number}ww" 14 | end 15 | 16 | 17 | def transcribe_balance_response(transcription_text, language = :english) 18 | mg = MessageGenerator.new(language) 19 | 20 | # Deal with a failed transcription 21 | # You do not need to change this. :D 22 | if transcription_text == nil 23 | return mg.having_trouble_try_again_message 24 | end 25 | 26 | # Deal with an invalid card number 27 | ### Step 5. EXAMPLE — Edit for your state! ### 28 | phrase_indicating_invalid_card_number = "having trouble locating" 29 | 30 | if transcription_text.include?(phrase_indicating_invalid_card_number) 31 | return mg.card_number_not_found_message 32 | end 33 | 34 | # Deal with a successful balance transcription 35 | ### Step 6. EXAMPLE — Edit for your state! ### 36 | text_with_dollar_amounts = DollarAmountsProcessor.new.process(transcription_text) 37 | regex_matches = text_with_dollar_amounts.scan(/(\$\S+)/) 38 | if regex_matches.count > 0 39 | ebt_amount = clean_trailing_period(regex_matches[0][0]) 40 | return mg.balance_message(ebt_amount) 41 | end 42 | 43 | # Deal with any other transcription (catching weird errors) 44 | # You do not need to change this. :D 45 | return mg.having_trouble_try_again_message 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/state_handler/pa.rb: -------------------------------------------------------------------------------- 1 | # Step 0. Change "::Example" below to a state abbreviation 2 | # For example, "::PA" for Pennsylvania 3 | class StateHandler::PA < StateHandler::Base 4 | 5 | # Step 1. EXAMPLE — Edit for your state! 6 | PHONE_NUMBER = '+18883287366' 7 | 8 | # Step 2. EXAMPLE — Edit for your state! 9 | ALLOWED_NUMBER_OF_EBT_CARD_DIGITS = [19] 10 | 11 | def button_sequence(ebt_number) 12 | # Step 3. EXAMPLE — Edit for your state! 13 | "wwww1wwww#{ebt_number}ww" 14 | end 15 | 16 | def transcribe_balance_response(transcription_text, language = :english) 17 | mg = MessageGenerator.new(language) 18 | 19 | # Deal with a failed transcription 20 | # You do not need to change this. :D 21 | if transcription_text == nil 22 | return mg.having_trouble_try_again_message 23 | end 24 | 25 | # Deal with an invalid card number 26 | ### Step 4. EXAMPLE — Edit for your state! ### 27 | phrase_indicating_invalid_card_number = "invalid card number" 28 | 29 | if transcription_text.downcase.include?(phrase_indicating_invalid_card_number) 30 | return mg.card_number_not_found_message 31 | end 32 | 33 | # Deal with a successful balance transcription 34 | ### Step 5. EXAMPLE — Edit for your state! ### 35 | text_with_dollar_amounts = DollarAmountsProcessor.new.process(transcription_text) 36 | regex_matches = text_with_dollar_amounts.scan(/(\$\S+)/) 37 | if regex_matches.count > 1 38 | ebt_amount = clean_trailing_period(regex_matches[0][0]) 39 | cash_amount = clean_trailing_period(regex_matches[1][0]) 40 | return mg.balance_message(ebt_amount, cash: cash_amount) 41 | end 42 | 43 | # Deal with any other transcription (catching weird errors) 44 | # You do not need to change this. :D 45 | return mg.having_trouble_try_again_message 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/lib/balance_log_analyzer_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require File.expand_path('../../../lib/balance_log_analyzer', __FILE__) 3 | 4 | describe BalanceLogAnalyzer::MessageAnalyzer do 5 | describe '#contains_balance_response?' do 6 | it 'returns true for valid English balance responses' do 7 | helper = BalanceLogAnalyzer::MessageAnalyzer.new 8 | 9 | ex1 = "Hi! Your food stamp balance is $4.23 and your cash balance is $0." 10 | expect(helper.contains_balance_response?(ex1)).to eq(true) 11 | 12 | ex2 = "I'm sorry! We're having trouble contacting the EBT system right now. Please try again in a few minutes or call this # and press 1 to use the state phone system." 13 | expect(helper.contains_balance_response?(ex2)).to eq(true) 14 | 15 | ex3 = "I'm sorry, that card number was not found. Please try again." 16 | expect(helper.contains_balance_response?(ex3)).to eq(true) 17 | end 18 | 19 | it 'returns false for other app message' do 20 | helper = BalanceLogAnalyzer::MessageAnalyzer.new 21 | 22 | ex4 = "Thanks! Please wait 1-2 minutes while we check your balance." 23 | expect(helper.contains_balance_response?(ex4)).to eq(false) 24 | 25 | ex5 = "Sorry! That number doesn't look right. Please reply with your EBT card number." 26 | expect(helper.contains_balance_response?(ex5)).to eq(false) 27 | 28 | ex6 = "Hi there! Reply to this message with your EBT card number and we'll check your balance for you. For more info, text ABOUT." 29 | expect(helper.contains_balance_response?(ex6)).to eq(false) 30 | 31 | ex7 = "This is a free text service provided by non-profit Code for America for checking your EBT balance (standard rates apply). For more info go to www.c4a.me/balance" 32 | expect(helper.contains_balance_response?(ex7)).to eq(false) 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/lib/phone_number_processor_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../phone_number_processor_spec_helper', __FILE__) 2 | 3 | describe PhoneNumberProcessor do 4 | before(:each) do 5 | VCR.use_cassette('phone_number_api_call') do 6 | @pnp = PhoneNumberProcessor.new 7 | end 8 | end 9 | 10 | describe '#twilio_number?' do 11 | context 'given a phone number that is a known Twilio number' do 12 | let(:twilio_phone_number) { '+14151112222' } 13 | 14 | it 'returns true' do 15 | result = @pnp.twilio_number?(twilio_phone_number) 16 | expect(result).to eq(true) 17 | end 18 | end 19 | 20 | context 'given a phone number that is a known Twilio number' do 21 | let(:not_a_twilio_phone_number) { '+10009998888' } 22 | 23 | it 'returns true' do 24 | result = @pnp.twilio_number?(not_a_twilio_phone_number) 25 | expect(result).to eq(false) 26 | end 27 | end 28 | end 29 | 30 | describe '#language_for' do 31 | context "given a phone number with 'spanish' in its friendly name" do 32 | let(:twilio_phone_number) { '+14151112222' } 33 | 34 | it 'returns :spanish' do 35 | result = @pnp.language_for(twilio_phone_number) 36 | expect(result).to eq(:spanish) 37 | end 38 | end 39 | 40 | context "given a phone number missing a + sign" do 41 | let(:twilio_phone_number) { '14151112222' } 42 | 43 | it 'returns the correct language' do 44 | result = @pnp.language_for(twilio_phone_number) 45 | expect(result).to eq(:spanish) 46 | end 47 | end 48 | 49 | context "given a phone number with no language in its friendly name" do 50 | let(:twilio_phone_number) { '+15103334444' } 51 | 52 | it 'returns :english' do 53 | result = @pnp.language_for(twilio_phone_number) 54 | expect(result).to eq(:english) 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/balance_log_analyzer.rb: -------------------------------------------------------------------------------- 1 | module BalanceLogAnalyzer 2 | class DelayedBalanceResponseAnalysis 3 | attr_reader :most_recent_thanks_msg, 4 | :time_thanks_message_sent, 5 | :phone_number_that_should_receive_balance 6 | 7 | def initialize(messages) 8 | @most_recent_thanks_msg = find_most_recent_thanks_message_more_than_5_mins_old(messages) 9 | @time_thanks_message_sent = Time.parse(most_recent_thanks_msg.date_sent) 10 | @phone_number_that_should_receive_balance = most_recent_thanks_msg.to 11 | balance_responses_to_waiting_person = messages.select do |m| 12 | m.to == phone_number_that_should_receive_balance && 13 | (Time.parse(m.date_sent) - time_thanks_message_sent) > 0 && 14 | MessageAnalyzer.new.contains_balance_response?(m.body) 15 | end 16 | if balance_responses_to_waiting_person.count > 0 17 | @are_messages_delayed = false 18 | else 19 | @are_messages_delayed = true 20 | @problem_description = "Missing balance response: User with number ending '#{@phone_number_that_should_receive_balance[-5..-1]}' did not receive a response within 5 minutes to their request at #{@time_thanks_message_sent.in_time_zone('Pacific Time (US & Canada)').strftime("%Y-%m-%d %H:%M:%S")} Pacific. 'Thanks' message SID: #{@most_recent_thanks_msg.sid}" 21 | end 22 | end 23 | 24 | def messages_delayed? 25 | @are_messages_delayed 26 | end 27 | 28 | def problem_description 29 | @problem_description || 'No problem' 30 | end 31 | 32 | private 33 | def find_most_recent_thanks_message_more_than_5_mins_old(message_array) 34 | message_array.select do |m| 35 | (Time.now - Time.parse(m.date_sent)) > 300 && m.body.include?('Thanks! Please wait') 36 | end.max_by do |m| 37 | Time.parse(m.date_sent) 38 | end 39 | end 40 | end 41 | 42 | class MessageAnalyzer 43 | def contains_balance_response?(string) 44 | string.include?("Hi! Your food") or 45 | string.include?("I'm sorry! We're having trouble") or 46 | string.include?("I'm sorry, that card number was not found") 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/state_handler/mo.rb: -------------------------------------------------------------------------------- 1 | class StateHandler::MO < StateHandler::Base 2 | PHONE_NUMBER = '+18009977777' 3 | ALLOWED_NUMBER_OF_EBT_CARD_DIGITS = [16] 4 | 5 | def button_sequence(ebt_number) 6 | "wwwwwwwwwwwwww1wwwwwwwwwwwwwwwwww2wwwwwwww#{ebt_number}" 7 | end 8 | 9 | def transcribe_balance_response(transcription_text, language = :english) 10 | BalanceTranscriber.new(language).transcribe_balance_response(transcription_text) 11 | end 12 | 13 | class BalanceTranscriber 14 | attr_reader :language 15 | 16 | def initialize(language) 17 | @language = language 18 | if language == :spanish 19 | extend SpanishTranscriptionMessages 20 | else 21 | extend EnglishTranscriptionMessages 22 | end 23 | end 24 | 25 | def transcribe_balance_response(transcription_text) 26 | if transcription_text == nil 27 | return having_trouble_try_again_message 28 | end 29 | text_with_dollar_amounts = DollarAmountsProcessor.new.process(transcription_text) 30 | regex_matches = text_with_dollar_amounts.scan(/(\$\S+)/) 31 | if transcription_text.include?("say I don't have it") 32 | card_number_not_found_message 33 | elsif regex_matches.count > 0 34 | ebt_amount = regex_matches[0][0] 35 | balance_message_for(ebt_amount) 36 | else 37 | having_trouble_try_again_message 38 | end 39 | end 40 | 41 | module EnglishTranscriptionMessages 42 | def having_trouble_try_again_message 43 | "I'm really sorry! We're having trouble contacting the EBT system right now. Please text your EBT # again in a few minutes." 44 | end 45 | 46 | def card_number_not_found_message 47 | "I'm sorry, that card number was not found. Please try again." 48 | end 49 | 50 | def balance_message_for(ebt_amount) 51 | "Hi! Your food stamp balance is #{ebt_amount}." 52 | end 53 | end 54 | 55 | module SpanishTranscriptionMessages 56 | def having_trouble_try_again_message 57 | "Lo siento! Actualmente estamos teniendo problemas comunicándonos con el sistema de EBT. Favor de enviar su # de EBT por texto en unos minutos." 58 | end 59 | 60 | def card_number_not_found_message 61 | "Lo siento, no se encontró el número de tarjeta. Por favor, inténtelo de nuevo." 62 | end 63 | 64 | def balance_message_for(ebt_amount) 65 | "Hola! El saldo de su cuenta de estampillas para comida es #{ebt_amount}." 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /spec/fixtures/vcr_cassettes/messages-for-status-check-system-down.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: get 5 | uri: https://mytwiliosid:mytwilioauth@api.twilio.com/2010-04-01/Accounts/mytwiliosid/Messages.json 6 | body: 7 | encoding: US-ASCII 8 | string: '' 9 | headers: 10 | Accept: 11 | - application/json 12 | Accept-Charset: 13 | - utf-8 14 | User-Agent: 15 | - twilio-ruby/3.11.6 (ruby/x86_64-darwin14.0 2.1.5-p273) 16 | Accept-Encoding: 17 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 18 | response: 19 | status: 20 | code: 200 21 | message: OK 22 | headers: 23 | Access-Control-Allow-Origin: 24 | - https://www.twilio.com 25 | Content-Type: 26 | - application/json; charset=utf-8 27 | Date: 28 | - Wed, 29 Apr 2015 22:42:07 GMT 29 | X-Powered-By: 30 | - AT-5000 31 | X-Shenanigans: 32 | - none 33 | Content-Length: 34 | - '41784' 35 | Connection: 36 | - keep-alive 37 | body: 38 | encoding: UTF-8 39 | string: '{"first_page_uri": "/2010-04-01/Accounts/mytwiliosid/Messages.json?PageSize=50&Page=0", 40 | "end": 49, "previous_page_uri": null, "messages": [{"sid": "fakesid2", "date_created": "Wed, 29 Apr 41 | 2015 22:34:37 +0000", "date_updated": "Wed, 29 Apr 2015 22:34:37 +0000", "date_sent": 42 | "Wed, 29 Apr 2015 22:34:37 +0000", "account_sid": "mytwiliosid", 43 | "to": "+12223334444", "from": "+14150001111", "body": "Thanks! Please wait 44 | 1-2 minutes while we check your balance.", "status": "delivered", "num_segments": 45 | "1", "num_media": "0", "direction": "outbound-api", "api_version": "2010-04-01", 46 | "price": "-0.00562", "price_unit": "USD", "error_code": null, "error_message": 47 | null, "uri": "/2010-04-01/Accounts/mytwiliosid/Messages/fakesid2.json", 48 | "subresource_uris": {"media": "/2010-04-01/Accounts/mytwiliosid/Messages/fakesid2/Media.json"}}], 49 | "uri": "/2010-04-01/Accounts/mytwiliosid/Messages.json?PageSize=50&Page=0", 50 | "page_size": 50, "start": 0, "next_page_uri": "/2010-04-01/Accounts/mytwiliosid/Messages.json?PageSize=50&Page=1&PageToken=PASM75ee20a1801f4d458f73e288785642de", 51 | "num_pages": 4566, "total": 228284, "last_page_uri": "/2010-04-01/Accounts/mytwiliosid/Messages.json?PageSize=50&Page=4565", 52 | "page": 0}' 53 | http_version: 54 | recorded_at: Wed, 29 Apr 2015 22:42:08 GMT 55 | recorded_with: VCR 2.9.0 56 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | activesupport (4.2.5) 5 | i18n (~> 0.7) 6 | json (~> 1.7, >= 1.7.7) 7 | minitest (~> 5.1) 8 | thread_safe (~> 0.3, >= 0.3.4) 9 | tzinfo (~> 1.1) 10 | addressable (2.4.0) 11 | backports (3.6.7) 12 | builder (3.2.2) 13 | coderay (1.1.0) 14 | crack (0.4.3) 15 | safe_yaml (~> 1.0.0) 16 | diff-lcs (1.2.5) 17 | dotenv (2.0.2) 18 | foreman (0.78.0) 19 | thor (~> 0.19.1) 20 | hashdiff (0.2.3) 21 | i18n (0.7.0) 22 | json (1.8.3) 23 | jwt (1.5.2) 24 | kgio (2.10.0) 25 | method_source (0.8.2) 26 | mini_portile2 (2.0.0) 27 | minitest (5.8.3) 28 | multi_json (1.11.2) 29 | newrelic_rpm (3.14.1.311) 30 | nokogiri (1.6.7.1) 31 | mini_portile2 (~> 2.0.0.rc2) 32 | numbers_in_words (0.4.0) 33 | activesupport 34 | pry (0.10.3) 35 | coderay (~> 1.1.0) 36 | method_source (~> 0.8.1) 37 | slop (~> 3.4) 38 | rack (1.6.4) 39 | rack-protection (1.5.3) 40 | rack 41 | rack-ssl (1.4.1) 42 | rack 43 | rack-test (0.6.3) 44 | rack (>= 1.0) 45 | rack-timeout (0.3.2) 46 | raindrops (0.15.0) 47 | require_all (1.3.3) 48 | rspec (3.4.0) 49 | rspec-core (~> 3.4.0) 50 | rspec-expectations (~> 3.4.0) 51 | rspec-mocks (~> 3.4.0) 52 | rspec-core (3.4.1) 53 | rspec-support (~> 3.4.0) 54 | rspec-expectations (3.4.0) 55 | diff-lcs (>= 1.2.0, < 2.0) 56 | rspec-support (~> 3.4.0) 57 | rspec-mocks (3.4.0) 58 | diff-lcs (>= 1.2.0, < 2.0) 59 | rspec-support (~> 3.4.0) 60 | rspec-support (3.4.1) 61 | safe_yaml (1.0.4) 62 | sinatra (1.4.6) 63 | rack (~> 1.4) 64 | rack-protection (~> 1.4) 65 | tilt (>= 1.3, < 3) 66 | sinatra-contrib (1.4.6) 67 | backports (>= 2.0) 68 | multi_json 69 | rack-protection 70 | rack-test 71 | sinatra (~> 1.4.0) 72 | tilt (>= 1.3, < 3) 73 | slop (3.6.0) 74 | thor (0.19.1) 75 | thread_safe (0.3.5) 76 | tilt (2.0.1) 77 | timecop (0.8.0) 78 | twilio-ruby (4.9.0) 79 | builder (>= 2.1.2) 80 | jwt (~> 1.0) 81 | multi_json (>= 1.3.0) 82 | tzinfo (1.2.2) 83 | thread_safe (~> 0.1) 84 | unicorn (5.0.1) 85 | kgio (~> 2.6) 86 | rack 87 | raindrops (~> 0.7) 88 | vcr (3.0.0) 89 | webmock (1.22.3) 90 | addressable (>= 2.3.6) 91 | crack (>= 0.3.2) 92 | hashdiff 93 | 94 | PLATFORMS 95 | ruby 96 | 97 | DEPENDENCIES 98 | activesupport 99 | dotenv 100 | foreman 101 | newrelic_rpm 102 | nokogiri 103 | numbers_in_words 104 | pry 105 | rack-ssl 106 | rack-test 107 | rack-timeout 108 | require_all 109 | rspec 110 | sinatra 111 | sinatra-contrib 112 | timecop 113 | twilio-ruby 114 | unicorn 115 | vcr 116 | webmock 117 | 118 | RUBY VERSION 119 | ruby 2.2.4p230 120 | 121 | BUNDLED WITH 122 | 1.16.2 123 | -------------------------------------------------------------------------------- /spec/fixtures/vcr_cassettes/messages-for-status-check-system-working.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: get 5 | uri: https://mytwiliosid:mytwilioauth@api.twilio.com/2010-04-01/Accounts/mytwiliosid/Messages.json 6 | body: 7 | encoding: US-ASCII 8 | string: '' 9 | headers: 10 | Accept: 11 | - application/json 12 | Accept-Charset: 13 | - utf-8 14 | User-Agent: 15 | - twilio-ruby/3.11.6 (ruby/x86_64-darwin14.0 2.1.5-p273) 16 | Accept-Encoding: 17 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 18 | response: 19 | status: 20 | code: 200 21 | message: OK 22 | headers: 23 | Access-Control-Allow-Origin: 24 | - https://www.twilio.com 25 | Content-Type: 26 | - application/json; charset=utf-8 27 | Date: 28 | - Wed, 29 Apr 2015 22:42:07 GMT 29 | X-Powered-By: 30 | - AT-5000 31 | X-Shenanigans: 32 | - none 33 | Content-Length: 34 | - '41784' 35 | Connection: 36 | - keep-alive 37 | body: 38 | encoding: UTF-8 39 | string: '{"first_page_uri": "/2010-04-01/Accounts/mytwiliosid/Messages.json?PageSize=50&Page=0", 40 | "end": 49, "previous_page_uri": null, "messages": [{"sid": "fakesid1", "date_created": "Wed, 29 Apr 41 | 2015 22:35:41 +0000", "date_updated": "Wed, 29 Apr 2015 22:35:41 +0000", "date_sent": 42 | "Wed, 29 Apr 2015 22:35:41 +0000", "account_sid": "mytwiliosid", 43 | "to": "+12223334444", "from": "+14150001111", "body": "Hi! Your food stamp 44 | balance is $0 and your cash balance is $1.49.", "status": "delivered", "num_segments": 45 | "1", "num_media": "0", "direction": "outbound-api", "api_version": "2010-04-01", 46 | "price": "-0.00562", "price_unit": "USD", "error_code": null, "error_message": 47 | null, "uri": "/2010-04-01/Accounts/mytwiliosid/Messages/fakesid1.json", 48 | "subresource_uris": {"media": "/2010-04-01/Accounts/mytwiliosid/Messages/fakesid1/Media.json"}}, 49 | {"sid": "fakesid2", "date_created": "Wed, 29 Apr 50 | 2015 22:34:37 +0000", "date_updated": "Wed, 29 Apr 2015 22:34:37 +0000", "date_sent": 51 | "Wed, 29 Apr 2015 22:34:37 +0000", "account_sid": "mytwiliosid", 52 | "to": "+12223334444", "from": "+14150001111", "body": "Thanks! Please wait 53 | 1-2 minutes while we check your balance.", "status": "delivered", "num_segments": 54 | "1", "num_media": "0", "direction": "outbound-api", "api_version": "2010-04-01", 55 | "price": "-0.00562", "price_unit": "USD", "error_code": null, "error_message": 56 | null, "uri": "/2010-04-01/Accounts/mytwiliosid/Messages/fakesid2.json", 57 | "subresource_uris": {"media": "/2010-04-01/Accounts/mytwiliosid/Messages/fakesid2/Media.json"}}], 58 | "uri": "/2010-04-01/Accounts/mytwiliosid/Messages.json?PageSize=50&Page=0", 59 | "page_size": 50, "start": 0, "next_page_uri": "/2010-04-01/Accounts/mytwiliosid/Messages.json?PageSize=50&Page=1&PageToken=PASM75ee20a1801f4d458f73e288785642de", 60 | "num_pages": 4566, "total": 228284, "last_page_uri": "/2010-04-01/Accounts/mytwiliosid/Messages.json?PageSize=50&Page=4565", 61 | "page": 0}' 62 | http_version: 63 | recorded_at: Wed, 29 Apr 2015 22:42:08 GMT 64 | recorded_with: VCR 2.9.0 65 | -------------------------------------------------------------------------------- /spec/fixtures/vcr_cassettes/phone_number_api_call.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: get 5 | uri: https://mytwiliosid:mytwilioauth@api.twilio.com/2010-04-01/Accounts/mytwiliosid/IncomingPhoneNumbers.json 6 | body: 7 | encoding: US-ASCII 8 | string: '' 9 | headers: 10 | Accept: 11 | - application/json 12 | Accept-Charset: 13 | - utf-8 14 | User-Agent: 15 | - twilio-ruby/3.11.6 (ruby/x86_64-darwin11.0 2.1.1-p76) 16 | Accept-Encoding: 17 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 18 | response: 19 | status: 20 | code: 200 21 | message: OK 22 | headers: 23 | Date: 24 | - Thu, 04 Sep 2014 21:09:10 GMT 25 | Content-Type: 26 | - application/json; charset=utf-8 27 | Content-Length: 28 | - '7170' 29 | Connection: 30 | - close 31 | X-Powered-By: 32 | - AT-5000 33 | X-Shenanigans: 34 | - none 35 | Access-Control-Allow-Origin: 36 | - https://www.twilio.com 37 | body: 38 | encoding: UTF-8 39 | string: '{"first_page_uri": "/2010-04-01/Accounts/mytwiliosid/IncomingPhoneNumbers.json?Page=0&PageSize=50", 40 | "end": 1, "previous_page_uri": null, "incoming_phone_numbers": [{"sid": "fakesid1", 41 | "account_sid": "mytwiliosid", "friendly_name": "california-spanish", 42 | "phone_number": "+14151112222", "voice_url": "https://demo.twilio.com/welcome/voice/", 43 | "voice_method": "GET", "voice_fallback_url": "", "voice_fallback_method": 44 | "POST", "voice_caller_id_lookup": false, "date_created": "Sun, 31 Aug 2014 45 | 19:42:51 +0000", "date_updated": "Sun, 31 Aug 2014 21:18:02 +0000", "sms_url": 46 | "http://4352f121.ngrok.com/", "sms_method": "GET", "sms_fallback_url": "", 47 | "sms_fallback_method": "POST", "capabilities": {"voice": true, "sms": true, 48 | "mms": false}, "status_callback": "", "status_callback_method": "POST", "api_version": 49 | "2010-04-01", "voice_application_sid": "", "sms_application_sid": "", "uri": 50 | "/2010-04-01/Accounts/mytwiliosid/IncomingPhoneNumbers/fakesid1.json"}, 51 | {"sid": "fakesid2", "account_sid": "mytwiliosid", 52 | "friendly_name": "adwords-splash-page", "phone_number": "+15103334444", "voice_url": 53 | "https://example.com/voice_call", "voice_method": "POST", 54 | "voice_fallback_url": "", "voice_fallback_method": "POST", "voice_caller_id_lookup": 55 | false, "date_created": "Sun, 31 Aug 2014 20:10:34 +0000", "date_updated": 56 | "Sun, 31 Aug 2014 20:15:37 +0000", "sms_url": "https://example.com", 57 | "sms_method": "POST", "sms_fallback_url": "", "sms_fallback_method": "POST", 58 | "capabilities": {"voice": true, "sms": true, "mms": false}, "status_callback": 59 | "", "status_callback_method": "POST", "api_version": "2010-04-01", "voice_application_sid": 60 | "", "sms_application_sid": "", "uri": "/2010-04-01/Accounts/mytwiliosid/IncomingPhoneNumbers/fakesid2.json"}], 61 | "uri": "/2010-04-01/Accounts/mytwiliosid/IncomingPhoneNumbers.json", 62 | "page_size": 50, "num_pages": 1, "start": 0, "next_page_uri": null, "total": 63 | 2, "last_page_uri": "/2010-04-01/Accounts/mytwiliosid/IncomingPhoneNumbers.json?Page=0&PageSize=50", 64 | "page": 0}' 65 | http_version: 66 | recorded_at: Thu, 04 Sep 2014 21:09:10 GMT 67 | recorded_with: VCR 2.9.0 68 | -------------------------------------------------------------------------------- /lib/message_generator.rb: -------------------------------------------------------------------------------- 1 | class MessageGenerator 2 | attr_reader :language 3 | 4 | def initialize(language = :english) 5 | @language = language 6 | end 7 | 8 | def thanks_please_wait 9 | if language == :spanish 10 | "Gracias! Favor de esperar 1-2 minutos mientras verificamos su saldo de EBT." 11 | else 12 | "Thanks! Please wait 1-2 minutes while we check your balance." 13 | end 14 | end 15 | 16 | def balance_message(food_stamp_balance, optional_balances = {}) 17 | if language == :spanish 18 | if optional_balances[:cash] 19 | balance_message = "Hola! El saldo de su cuenta de estampillas para comida es #{food_stamp_balance} y su balance de dinero en efectivo es #{optional_balances[:cash]}." 20 | else 21 | balance_message = "Hola! El saldo de su cuenta de estampillas para comida es #{food_stamp_balance}." 22 | end 23 | else 24 | if optional_balances[:cash] 25 | balance_message = "Hi! Your food stamp balance is #{food_stamp_balance} and your cash balance is #{optional_balances[:cash]}." 26 | else 27 | balance_message = "Hi! Your food stamp balance is #{food_stamp_balance}." 28 | end 29 | end 30 | balance_message 31 | end 32 | 33 | def sorry_try_again(digits_array = []) 34 | if language == :spanish 35 | "Perdon, ese número de EBT no esta trabajando. Favor de intentarlo otra vez." 36 | else 37 | if digits_array == nil 38 | "Sorry! That number doesn't look right. Please reply with your EBT card number or ABOUT for more information." 39 | elsif digits_array.length == 1 40 | "Sorry! That number doesn't look right. Please reply with your #{digits_array[0]}-digit EBT card number or ABOUT for more information." 41 | elsif digits_array.length == 2 42 | "Sorry! That number doesn't look right. Please reply with your #{digits_array[0]}- or #{digits_array[1]}-digit EBT card number or ABOUT for more information." 43 | else 44 | "Sorry! That number doesn't look right. Please reply with your EBT card number or ABOUT for more information." 45 | end 46 | end 47 | end 48 | 49 | def welcome 50 | if language == :spanish 51 | 'Hola! Usted puede verificar su saldo de EBT por mensaje de texto. Solo responda a este mensaje con su número de tarjeta de EBT.' 52 | else 53 | "Hi there! Reply to this message with your EBT card number and we'll check your balance for you. For more info, text ABOUT." 54 | end 55 | end 56 | 57 | def having_trouble_try_again_message 58 | if language == :spanish 59 | "Lo siento! Actualmente estamos teniendo problemas comunicándonos con el sistema de EBT. Favor de enviar su # de EBT por texto en unos minutos." 60 | else 61 | "I'm sorry! We're having trouble contacting the EBT system right now. Please try again in a few minutes or call this # and press 1 to use the state phone system." 62 | end 63 | end 64 | 65 | def card_number_not_found_message 66 | if language == :spanish 67 | "Lo siento, no se encontró el número de tarjeta. Por favor, inténtelo de nuevo." 68 | else 69 | "I'm sorry, that card number was not found. Please try again." 70 | end 71 | end 72 | 73 | def call_in_voice_file_url 74 | if language == :spanish 75 | 'https://s3-us-west-1.amazonaws.com/balance-cfa/balance-voice-splash-spanish-v2-012515.mp3' 76 | else 77 | 'https://s3-us-west-1.amazonaws.com/balance-cfa/balance-voice-splash-v4-012515.mp3' 78 | end 79 | end 80 | 81 | def more_info 82 | "This is a free text service by non-profit Code for America for checking your EBT balance (standard msg rates apply). For more info go to http://c4a.me/balance" 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # This file was generated by the `rspec --init` command. Conventionally, all 2 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 3 | # The generated `.rspec` file contains `--require spec_helper` which will cause this 4 | # file to always be loaded, without a need to explicitly require it in any files. 5 | # 6 | # Given that it is always loaded, you are encouraged to keep this file as 7 | # light-weight as possible. Requiring heavyweight dependencies from this file 8 | # will add to the boot time of your test suite on EVERY test run, even for an 9 | # individual file that may not need all of that loaded. Instead, make a 10 | # separate helper file that requires this one and then use it only in the specs 11 | # that actually need it. 12 | # 13 | # The `.rspec` file also contains a few flags that are not defaults but that 14 | # users commonly want. 15 | # 16 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 17 | 18 | require 'pry' 19 | require 'dotenv' 20 | 21 | RSpec.configure do |config| 22 | config.before do 23 | Dotenv.load 24 | end 25 | # The settings below are suggested to provide a good initial experience 26 | # with RSpec, but feel free to customize to your heart's content. 27 | =begin 28 | # These two settings work together to allow you to limit a spec run 29 | # to individual examples or groups you care about by tagging them with 30 | # `:focus` metadata. When nothing is tagged with `:focus`, all examples 31 | # get run. 32 | config.filter_run :focus 33 | config.run_all_when_everything_filtered = true 34 | 35 | # Many RSpec users commonly either run the entire suite or an individual 36 | # file, and it's useful to allow more verbose output when running an 37 | # individual spec file. 38 | if config.files_to_run.one? 39 | # Use the documentation formatter for detailed output, 40 | # unless a formatter has already been configured 41 | # (e.g. via a command-line flag). 42 | config.default_formatter = 'doc' 43 | end 44 | 45 | # Print the 10 slowest examples and example groups at the 46 | # end of the spec run, to help surface which specs are running 47 | # particularly slow. 48 | config.profile_examples = 10 49 | 50 | # Run specs in random order to surface order dependencies. If you find an 51 | # order dependency and want to debug it, you can fix the order by providing 52 | # the seed, which is printed after each run. 53 | # --seed 1234 54 | config.order = :random 55 | 56 | # Seed global randomization in this process using the `--seed` CLI option. 57 | # Setting this allows you to use `--seed` to deterministically reproduce 58 | # test failures related to randomization by passing the same `--seed` value 59 | # as the one that triggered the failure. 60 | Kernel.srand config.seed 61 | 62 | # rspec-expectations config goes here. You can use an alternate 63 | # assertion/expectation library such as wrong or the stdlib/minitest 64 | # assertions if you prefer. 65 | config.expect_with :rspec do |expectations| 66 | # Enable only the newer, non-monkey-patching expect syntax. 67 | # For more details, see: 68 | # - http://myronmars.to/n/dev-blog/2012/06/rspecs-new-expectation-syntax 69 | expectations.syntax = :expect 70 | end 71 | 72 | # rspec-mocks config goes here. You can use an alternate test double 73 | # library (such as bogus or mocha) by changing the `mock_with` option here. 74 | config.mock_with :rspec do |mocks| 75 | # Enable only the newer, non-monkey-patching expect syntax. 76 | # For more details, see: 77 | # - http://teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 78 | mocks.syntax = :expect 79 | 80 | # Prevents you from mocking or stubbing a method that does not exist on 81 | # a real object. This is generally recommended. 82 | mocks.verify_partial_doubles = true 83 | end 84 | =end 85 | end 86 | -------------------------------------------------------------------------------- /spec/lib/message_generator_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require File.expand_path('../../../lib/message_generator', __FILE__) 3 | 4 | describe MessageGenerator do 5 | it 'initializes with English by default' do 6 | mg = MessageGenerator.new 7 | expect(mg.language).to eq(:english) 8 | end 9 | 10 | context 'for English' do 11 | let(:mg) { MessageGenerator.new } 12 | 13 | describe '#thanks_please_wait' do 14 | it "says 'thanks please wait...'" do 15 | desired_message = "Thanks! Please wait 1-2 minutes while we check your balance." 16 | expect(mg.thanks_please_wait).to eq(desired_message) 17 | end 18 | end 19 | 20 | describe '#having_trouble_try_again_message' do 21 | it 'has the correct text' do 22 | desired_message = "I'm sorry! We're having trouble contacting the EBT system right now. Please try again in a few minutes or call this # and press 1 to use the state phone system." 23 | expect(mg.having_trouble_try_again_message).to eq(desired_message) 24 | end 25 | end 26 | 27 | describe '#balance_message' do 28 | context 'with single argument' do 29 | it 'reports just the food stamp balance' do 30 | balance_message = mg.balance_message("$123.45") 31 | expect(balance_message).to eq("Hi! Your food stamp balance is $123.45.") 32 | end 33 | end 34 | 35 | context 'with a cash balance in second argument' do 36 | it 'reports both food stamp and cash balances' do 37 | balance_message = mg.balance_message("$123.45", cash: "$42.11") 38 | desired_message = "Hi! Your food stamp balance is $123.45 and your cash balance is $42.11." 39 | expect(balance_message).to eq(desired_message) 40 | end 41 | end 42 | end 43 | 44 | describe '#sorry_try_again' do 45 | context 'with a single digit length for that state' do 46 | it "says 'sorry, try again...'" do 47 | digit_lengths = [16] 48 | desired_message = "Sorry! That number doesn't look right. Please reply with your 16-digit EBT card number or ABOUT for more information." 49 | response = mg.sorry_try_again(digit_lengths) 50 | expect(response).to eq(desired_message) 51 | end 52 | end 53 | 54 | context 'with multiple possible digit lengths in the state' do 55 | it "says 'sorry...' with multiple digits" do 56 | digit_lengths = [16, 19] 57 | desired_message = "Sorry! That number doesn't look right. Please reply with your 16- or 19-digit EBT card number or ABOUT for more information." 58 | response = mg.sorry_try_again(digit_lengths) 59 | expect(response).to eq(desired_message) 60 | end 61 | end 62 | 63 | context 'with no argument passed in' do 64 | it "says 'sorry, try again...'" do 65 | desired_message = "Sorry! That number doesn't look right. Please reply with your EBT card number or ABOUT for more information." 66 | response = mg.sorry_try_again 67 | expect(response).to eq(desired_message) 68 | end 69 | end 70 | 71 | context 'with nil passed in' do 72 | it "says 'sorry, try again...'" do 73 | desired_message = "Sorry! That number doesn't look right. Please reply with your EBT card number or ABOUT for more information." 74 | response = mg.sorry_try_again(nil) 75 | expect(response).to eq(desired_message) 76 | end 77 | end 78 | end 79 | 80 | describe '#call_in_voice_file_url' do 81 | it "gives the English s3 file URL" do 82 | url = 'https://s3-us-west-1.amazonaws.com/balance-cfa/balance-voice-splash-v4-012515.mp3' 83 | expect(mg.call_in_voice_file_url).to eq(url) 84 | end 85 | end 86 | end 87 | 88 | context 'for Spanish' do 89 | let(:mg) { MessageGenerator.new(:spanish) } 90 | 91 | describe '#thanks_please_wait' do 92 | it "says Spanish version of 'thanks please wait...'" do 93 | desired_message = "Gracias! Favor de esperar 1-2 minutos mientras verificamos su saldo de EBT." 94 | expect(mg.thanks_please_wait).to eq(desired_message) 95 | end 96 | end 97 | 98 | describe '#balance_message' do 99 | context 'with single argument' do 100 | it 'reports just the food stamp balance in Spanish' do 101 | balance_message = mg.balance_message("$123.45") 102 | expect(balance_message).to eq("Hola! El saldo de su cuenta de estampillas para comida es $123.45.") 103 | end 104 | end 105 | 106 | context 'with a cash balance in second argument' do 107 | it 'reports both food stamp and cash balances in Spanish' do 108 | balance_message = mg.balance_message("$123.45", cash: "$42.11") 109 | desired_message = "Hola! El saldo de su cuenta de estampillas para comida es $123.45 y su balance de dinero en efectivo es $42.11." 110 | expect(balance_message).to eq(desired_message) 111 | end 112 | end 113 | end 114 | 115 | describe '#sorry_try_again' do 116 | it "says Spanish version of 'sorry, try again...'" do 117 | desired_message = "Perdon, ese número de EBT no esta trabajando. Favor de intentarlo otra vez." 118 | expect(mg.sorry_try_again).to eq(desired_message) 119 | end 120 | end 121 | 122 | describe '#call_in_voice_file_url' do 123 | it "gives the Spanish s3 file URL" do 124 | url = 'https://s3-us-west-1.amazonaws.com/balance-cfa/balance-voice-splash-spanish-v2-012515.mp3' 125 | expect(mg.call_in_voice_file_url).to eq(url) 126 | end 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /app.rb: -------------------------------------------------------------------------------- 1 | require 'sinatra' 2 | require 'sinatra/json' 3 | require 'twilio-ruby' 4 | require 'rack/ssl' 5 | require 'active_support/core_ext/time' 6 | require File.expand_path('../lib/twilio_service', __FILE__) 7 | require File.expand_path('../lib/state_handler', __FILE__) 8 | require File.expand_path('../lib/phone_number_processor', __FILE__) 9 | require File.expand_path('../lib/message_generator', __FILE__) 10 | require File.expand_path('../lib/balance_log_analyzer', __FILE__) 11 | 12 | class EbtBalanceSmsApp < Sinatra::Base 13 | use Rack::SSL unless settings.environment == :development or settings.environment == :test 14 | if settings.environment == :production 15 | set :url_scheme, 'https' 16 | else 17 | set :url_scheme, 'http' 18 | end 19 | set :phone_number_processor, PhoneNumberProcessor.new 20 | 21 | configure :production do 22 | require 'newrelic_rpm' 23 | end 24 | 25 | helpers do 26 | def valid_phone_number?(phone_number) 27 | contains_good_number_of_phone_digits?(phone_number) && !one_of_our_twilio_numbers?(phone_number) 28 | end 29 | 30 | def contains_good_number_of_phone_digits?(phone_number) 31 | is_it_valid = (phone_number.length == 10) || (phone_number.length == 11 && phone_number[0] == '1') 32 | is_it_valid 33 | end 34 | 35 | def one_of_our_twilio_numbers?(phone_number) 36 | formatted_phone_number = convert_to_e164_phone_number(phone_number) 37 | settings.phone_number_processor.twilio_number?(formatted_phone_number) 38 | end 39 | 40 | def convert_to_e164_phone_number(phone_number) 41 | if phone_number.length == 10 42 | '+1' + phone_number 43 | elsif phone_number.length == 11 44 | '+' + phone_number 45 | end 46 | end 47 | end 48 | 49 | before do 50 | puts "Request details — #{request.request_method} #{request.url}" unless settings.environment == :test 51 | end 52 | 53 | post '/' do 54 | twilio_service = TwilioService.new(Twilio::REST::Client.new(ENV['TWILIO_SID'], ENV['TWILIO_AUTH'])) 55 | texter_phone_number = params["From"] 56 | inbound_twilio_number = params["To"] 57 | state_abbreviation = params["ToState"] || "no_state_abbreviation_received" 58 | state_handler = StateHandler.for(state_abbreviation) 59 | ebt_number = state_handler.extract_valid_ebt_number_from_text(params["Body"]) 60 | twiml_url = "#{settings.url_scheme}://" 61 | twiml_url << "#{request.env['HTTP_HOST']}/get_balance" 62 | twiml_url << "?phone_number=#{texter_phone_number}" 63 | twiml_url << "&twilio_phone_number=#{inbound_twilio_number}" 64 | twiml_url << "&state=#{state_abbreviation}" 65 | twiml_url << "&ebt_number=#{ebt_number}" 66 | twiml_url << "&balance_check_id=#{SecureRandom.hex}" 67 | language = settings.phone_number_processor.language_for(inbound_twilio_number) 68 | message_generator = MessageGenerator.new(language) 69 | # Need to rescue Twilio API errors 70 | begin 71 | if ebt_number != :invalid_number 72 | twilio_service.send_text( 73 | to: texter_phone_number, 74 | from: inbound_twilio_number, 75 | body: message_generator.thanks_please_wait 76 | ) 77 | twilio_service.make_call( 78 | url: twiml_url, 79 | to: state_handler.phone_number, 80 | from: inbound_twilio_number, 81 | method: "GET" 82 | ) 83 | elsif params["Body"].downcase.include?('about') 84 | twilio_service.send_text( 85 | to: texter_phone_number, 86 | from: inbound_twilio_number, 87 | body: message_generator.more_info 88 | ) 89 | else 90 | twilio_service.send_text( 91 | to: texter_phone_number, 92 | from: inbound_twilio_number, 93 | body: message_generator.sorry_try_again(state_handler.allowed_number_of_ebt_card_digits) 94 | ) 95 | end 96 | rescue Twilio::REST::RequestError => e 97 | puts "Twilio API request error - \"#{e.message}\"" 98 | end 99 | end 100 | 101 | get '/get_balance' do 102 | phone_number = params[:phone_number].strip 103 | twilio_number = params[:twilio_phone_number].strip 104 | balance_check_id = params[:balance_check_id] 105 | state = params[:state] 106 | state_handler = StateHandler.for(state) 107 | Twilio::TwiML::Response.new do |r| 108 | r.Play digits: state_handler.button_sequence(params['ebt_number']) 109 | r.Record transcribe: true, 110 | transcribeCallback: "#{settings.url_scheme}://#{request.env['HTTP_HOST']}/#{state}/#{phone_number}/#{twilio_number}/#{balance_check_id}/send_balance", 111 | timeout: 10, 112 | maxLength: state_handler.max_message_length 113 | end.text 114 | end 115 | 116 | post '/get_balance' do 117 | # Twilio posts unused data here; necessary simply to avoid 404 error in logs 118 | response = < 120 | 121 | 122 | EOF 123 | end 124 | 125 | post '/:state/:to_phone_number/:from_phone_number/:balance_check_id/send_balance' do 126 | twilio_phone_number = params[:from_phone_number] 127 | language = settings.phone_number_processor.language_for(twilio_phone_number) 128 | handler = StateHandler.for(params[:state]) 129 | processed_balance_response_for_user = handler.transcribe_balance_response(params["TranscriptionText"], language) 130 | twilio_service = TwilioService.new(Twilio::REST::Client.new(ENV['TWILIO_SID'], ENV['TWILIO_AUTH'])) 131 | twilio_service.send_text( 132 | to: params[:to_phone_number].strip, 133 | from: params[:from_phone_number], 134 | body: processed_balance_response_for_user 135 | ) 136 | end 137 | 138 | post '/voice_call' do 139 | twilio_service = TwilioService.new(Twilio::REST::Client.new(ENV['TWILIO_SID'], ENV['TWILIO_AUTH'])) 140 | caller_phone_number = params["From"] 141 | inbound_twilio_number = params["To"] 142 | state_handler = StateHandler.for(params["ToState"]) 143 | language = settings.phone_number_processor.language_for(inbound_twilio_number) 144 | message_generator = MessageGenerator.new(language) 145 | response = < 147 | 148 | 149 | #{message_generator.call_in_voice_file_url} 150 | 151 | http://twimlets.com/forward?PhoneNumber=#{state_handler.phone_number} 152 | 153 | EOF 154 | end 155 | 156 | post '/welcome' do 157 | puts "/welcome request params: #{params}" unless settings.environment == :test 158 | digits_only_input = params['texter_phone_number'].gsub(/\D/, "") 159 | if valid_phone_number?(digits_only_input) 160 | formatted_phone_number = convert_to_e164_phone_number(digits_only_input) 161 | inbound_twilio_number = params["inbound_twilio_number"] 162 | language = settings.phone_number_processor.language_for(inbound_twilio_number) 163 | message_generator = MessageGenerator.new(language) 164 | twilio_service = TwilioService.new(Twilio::REST::Client.new(ENV['TWILIO_SID'], ENV['TWILIO_AUTH'])) 165 | twilio_service.send_text( 166 | to: formatted_phone_number, 167 | from: inbound_twilio_number, 168 | body: message_generator.welcome 169 | ) 170 | "Great! I just sent you a text message with instructions. I hope you find this service useful!" 171 | else 172 | "Sorry! That number doesn't look right. Please go back and try again." 173 | end 174 | end 175 | 176 | get '/.well-known/status' do 177 | client = Twilio::REST::Client.new(ENV['TWILIO_SID'], ENV['TWILIO_AUTH']) 178 | messages = client.account.messages.list 179 | delay_analysis = BalanceLogAnalyzer::DelayedBalanceResponseAnalysis.new(messages) 180 | response_hash = Hash.new 181 | response_hash[:dependencies] = [ "twilio" ] 182 | response_hash[:updated] = Time.now.to_i 183 | response_hash[:resources] = {} 184 | if delay_analysis.messages_delayed? 185 | response_hash[:status] = delay_analysis.problem_description 186 | else 187 | response_hash[:status] = 'ok' 188 | end 189 | json response_hash 190 | end 191 | end 192 | -------------------------------------------------------------------------------- /HOW_TO_ADD_A_STATE.md: -------------------------------------------------------------------------------- 1 | Note: We aren't accepting contributions right now. Sorry! See the readme for details: https://github.com/codeforamerica/balance#contribute 2 | 3 | # How to add a state to Balance 4 | 5 | ## Collect state info (no coding necessary!) 6 | 7 | The first step for adding a state is finding some basic information. We keep this info in a Google Spreadsheet here: 8 | https://docs.google.com/a/codeforamerica.org/spreadsheets/d/12jOXkz1bt7bHzhuXhHYdHhTd45IgrmyVg8-hw93BjIo/edit?usp=sharing 9 | 10 | If your state's info is _not_ there — add it! 11 | 12 | 1) The **existing phone number for checking SNAP balance** in your state. You can find this by searching on Google for "STATENAME snap ebt balance." 13 | 14 | 2) **\# of digits** an EBT card has in your state (check your state's SNAP web site) 15 | 16 | 3) The **button push sequence** for checking your balance. Figure this out by calling the phone number and writing down the steps for checking your balance in English. Count the seconds you wait and write that down too. 17 | 18 | Here's an example for Massachusetts: 19 | 20 | ![Example for Massachusetts](screenshots/state-example.png) 21 | 22 | 23 | ## Write a basic handler (for developers) 24 | 1) Fork the Balance repo to your Github account 25 | 26 | 2) Clone and `cd` into the project: 27 | ``` 28 | git clone https://github.com/[USERNAME]/balance.git 29 | cd balance 30 | ``` 31 | 32 | 3) Check out a feature branch for adding your state, for example: 33 | ``` 34 | git checkout -b add-massachusetts 35 | ``` 36 | 37 | 4) Copy the `example.rb` state handler into a new file named after your state's abbreviation. For Massachusetts, we would do: 38 | ``` 39 | cp lib/state_handler/example.rb lib/state_handler/ma.rb 40 | ``` 41 | 42 | 5) Edit your new state handler file. The top part will look like this: 43 | ```ruby 44 | # Step 1. Change "::Example" below to a state abbreviation 45 | # For example, "::PA" for Pennsylvania 46 | class StateHandler::Example < StateHandler::Base 47 | 48 | # Step 2. EXAMPLE — Edit for your state! 49 | PHONE_NUMBER = '+1222333444' 50 | 51 | # Step 3. EXAMPLE — Edit for your state! 52 | ALLOWED_NUMBER_OF_EBT_CARD_DIGITS = [16] 53 | 54 | def button_sequence(ebt_number) 55 | # Step 4. EXAMPLE — Edit for your state! 56 | "wwww1wwww#{ebt_number}ww" 57 | end 58 | 59 | # … 60 | ``` 61 | - Add your state's information for the steps shown. 62 | - Change `StateHandler::Example` to a state abbreviation 63 | - For example, `StateHandler::PA` for Pennsylvania 64 | 65 | - For the button sequence: 66 | - Use `w` to mean "wait 1/2 of a second" 67 | - Put `#{ebt_number}` where you would enter the EBT # 68 | 69 | With our MA example, our file will now look something like this: 70 | 71 | ```ruby 72 | class StateHandler::MA < StateHandler::Base 73 | PHONE_NUMBER = '+18009972555' 74 | ALLOWED_NUMBER_OF_EBT_CARD_DIGITS = [18] 75 | 76 | def button_sequence(ebt_number) 77 | "wwww1#{ebt_number}" 78 | end 79 | 80 | # … 81 | ``` 82 | 83 | 6) Add and commit your changes: 84 | ``` 85 | git add . 86 | git commit -m "Initial work on MA handler" 87 | ``` 88 | 89 | ## Test your basic handler 90 | 91 | 1) Now, find one of the project leads and ask to be added as a collaborator to the Heroku dev app set up for this. 92 | 93 | 2) Once you're a collaborator, add a git remote for the Heroku test app. For MA, this would be: 94 | `git remote add heroku git@heroku.com:balance-summit-ma.git` 95 | 96 | 3) Now deploy your branch to Heroku, for example: 97 | `git push heroku add-massachusetts:master` 98 | 99 | 4) Now the fun part! Ask one of the project leads for (1) the Twilio phone number configured for your state (also in [the spreadsheet](https://docs.google.com/a/codeforamerica.org/spreadsheets/d/12jOXkz1bt7bHzhuXhHYdHhTd45IgrmyVg8-hw93BjIo/edit#gid=0)) and (2) the sample EBT card #. 100 | 101 | **Send a text message containing the EBT card number to the Twilio phone number!** 102 | 103 | Your basic handler will send back the exact transcription of what the phone line says. 104 | 105 | 5) Tinker with your button sequence (redeploying it to Heroku to test) if you don't get the balance in the text. A common solution is to add `ww` after `#{ebt_number}` to tell Twilio to wait a second before starting recording. 106 | 107 | 108 | ## Write a balance transcriber 109 | Next, we will deal with invalid card numbers and successful balances. 110 | 111 | 1) When you can get the balance coming through with your basic handler, open your state handler and **uncomment the `transcribe_balance_response` method**, which will look like this: 112 | 113 | ```ruby 114 | def transcribe_balance_response(transcription_text, language = :english) 115 | mg = MessageGenerator.new(language) 116 | 117 | # Deal with a failed transcription 118 | # You do not need to change this. :D 119 | if transcription_text == nil 120 | return mg.having_trouble_try_again_message 121 | end 122 | 123 | # Deal with an invalid card number 124 | ### Step 5. EXAMPLE — Edit for your state! ### 125 | phrase_indicating_invalid_card_number = "CHANGE ME" 126 | 127 | if transcription_text.include?(phrase_indicating_invalid_card_number) 128 | return mg.card_number_not_found_message 129 | end 130 | 131 | # Deal with a successful balance transcription 132 | ### Step 6. EXAMPLE — Edit for your state! ### 133 | regex_matches = transcription_text.scan(/(\$\S+)/) 134 | if regex_matches.count > 1 135 | ebt_amount = regex_matches[0][0] 136 | return "Hi! Your food stamp balance is #{ebt_amount}." 137 | end 138 | 139 | # Deal with any other transcription (catching weird errors) 140 | # You do not need to change this. :D 141 | return mg.having_trouble_try_again_message 142 | end 143 | ``` 144 | 145 | 2) Deal with **invalid card numbers** 146 | 147 | To write this, first send a text message to your basic handler with the EBT number, but change the first two digits to be `0` (for example, instead of `50771234` send a text with `00771234`). 148 | 149 | Look at what gets texted back to you, and look for a unique phrase that is **not** in successful balance responses, **and** which is likely to be transcribed consistently — for example, "invalid card number". 150 | 151 | Change the code to put this phrase in there: 152 | 153 | ```ruby 154 | phrase_indicating_invalid_card_number = "invalid card number" 155 | ``` 156 | 157 | 3) Format a **successful balance transcription** 158 | 159 | Next, we want to write some code that takes a successful balance response and formats it a bit more nicely — it should say "Hi! Your food stamp balance is…" and optionally have more information. 160 | 161 | In most cases, this is easily done with a regex looking for dollar amounts like `$123.45`. 162 | 163 | The example code there is simple — it just looks for the first dollar amount match: 164 | 165 | ```ruby 166 | regex_matches = transcription_text.scan(/(\$\S+)/) 167 | if regex_matches.count > 1 168 | ebt_amount = regex_matches[0][0] 169 | return "Hi! Your food stamp balance is #{ebt_amount}." 170 | end 171 | ``` 172 | 173 | This may even work out of the box for lots of states! 174 | 175 | For an example of a more complicated transcription, we can look at California which also provides a cash amount (the second dollar amount read off): 176 | 177 | ```ruby 178 | regex_matches = transcription_text.scan(/(\$\S+)/) 179 | if regex_matches.count > 1 180 | ebt_amount = regex_matches[0][0] 181 | cash_amount = regex_matches[1][0] 182 | return "Hi! Your food stamp balance is #{ebt_amount} and your cash balance is #{cash_amount}." 183 | end 184 | ``` 185 | ## PR + CELEBRATE!! 186 | 1) Commit and push your branch to Github 187 | ``` 188 | git add . 189 | git commit -m "Finished work on MA!" 190 | git push origin add-massachusetts 191 | ``` 192 | 193 | 2) Go to your forked repo and create a pull request 194 | 195 | ![Pull Example for Massachusetts](screenshots/state-pull-example.png) 196 | 197 | 3) PARTY! 198 | - Add your Twitter handle to the [Google Spreadsheet](https://docs.google.com/a/codeforamerica.org/spreadsheets/d/12jOXkz1bt7bHzhuXhHYdHhTd45IgrmyVg8-hw93BjIo/edit#gid=0) so we can let you know when it's live. 199 | - Send [Dave](http://www.twitter.com/allafarce) some twitter love! Include a link to your PR to make him extra happy :) 200 | -------------------------------------------------------------------------------- /config/newrelic.yml: -------------------------------------------------------------------------------- 1 | # 2 | # This file configures the New Relic Agent. New Relic monitors 3 | # Ruby, Java, .NET, PHP, and Python applications with deep visibility and low overhead. 4 | # For more information, visit www.newrelic.com. 5 | # 6 | # Generated June 03, 2013 7 | # 8 | # This configuration file is custom generated for Barsoom 9 | 10 | 11 | # Here are the settings that are common to all environments 12 | common: &default_settings 13 | # ============================== LICENSE KEY =============================== 14 | 15 | # You must specify the license key associated with your New Relic 16 | # account. This key binds your Agent's data to your account in the 17 | # New Relic service. 18 | license_key: '<%= ENV["NEW_RELIC_LICENSE_KEY"] %>' 19 | 20 | # Agent Enabled (Ruby/Rails Only) 21 | # Use this setting to force the agent to run or not run. 22 | # Default is 'auto' which means the agent will install and run only 23 | # if a valid dispatcher such as Mongrel is running. This prevents 24 | # it from running with Rake or the console. Set to false to 25 | # completely turn the agent off regardless of the other settings. 26 | # Valid values are true, false and auto. 27 | # 28 | # agent_enabled: auto 29 | 30 | # Application Name Set this to be the name of your application as 31 | # you'd like it show up in New Relic. The service will then auto-map 32 | # instances of your application into an "application" on your 33 | # dashboard page. If you want to map this instance into multiple 34 | # apps, like "AJAX Requests" and "All UI" then specify a semicolon 35 | # separated list of up to three distinct names, or a yaml list. 36 | # Defaults to the capitalized RAILS_ENV or RACK_ENV (i.e., 37 | # Production, Staging, etc) 38 | # 39 | # Example: 40 | # 41 | # app_name: 42 | # - Ajax Service 43 | # - All Services 44 | # 45 | app_name: <%= ENV["NEW_RELIC_APP_NAME"] %> 46 | 47 | # When "true", the agent collects performance data about your 48 | # application and reports this data to the New Relic service at 49 | # newrelic.com. This global switch is normally overridden for each 50 | # environment below. (formerly called 'enabled') 51 | monitor_mode: true 52 | 53 | # Developer mode should be off in every environment but 54 | # development as it has very high overhead in memory. 55 | developer_mode: false 56 | 57 | # The newrelic agent generates its own log file to keep its logging 58 | # information separate from that of your application. Specify its 59 | # log level here. 60 | log_level: info 61 | 62 | # Optionally set the path to the log file This is expanded from the 63 | # root directory (may be relative or absolute, e.g. 'log/' or 64 | # '/var/log/') The agent will attempt to create this directory if it 65 | # does not exist. 66 | # log_file_path: 'log' 67 | 68 | # Optionally set the name of the log file, defaults to 'newrelic_agent.log' 69 | # log_file_name: 'newrelic_agent.log' 70 | 71 | # The newrelic agent communicates with the service via https by default. This 72 | # prevents eavesdropping on the performance metrics transmitted by the agent. 73 | # The encryption required by SSL introduces a nominal amount of CPU overhead, 74 | # which is performed asynchronously in a background thread. If you'd prefer 75 | # to send your metrics over http uncomment the following line. 76 | # ssl: false 77 | 78 | #============================== Browser Monitoring =============================== 79 | # New Relic Real User Monitoring gives you insight into the performance real users are 80 | # experiencing with your website. This is accomplished by measuring the time it takes for 81 | # your users' browsers to download and render your web pages by injecting a small amount 82 | # of JavaScript code into the header and footer of each page. 83 | browser_monitoring: 84 | # By default the agent automatically injects the monitoring JavaScript 85 | # into web pages. Set this attribute to false to turn off this behavior. 86 | auto_instrument: true 87 | 88 | # Proxy settings for connecting to the New Relic server. 89 | # 90 | # If a proxy is used, the host setting is required. Other settings 91 | # are optional. Default port is 8080. 92 | # 93 | # proxy_host: hostname 94 | # proxy_port: 8080 95 | # proxy_user: 96 | # proxy_pass: 97 | 98 | # The agent can optionally log all data it sends to New Relic servers to a 99 | # separate log file for human inspection and auditing purposes. To enable this 100 | # feature, change 'enabled' below to true. 101 | # See: https://newrelic.com/docs/ruby/audit-log 102 | audit_log: 103 | enabled: false 104 | 105 | # Tells transaction tracer and error collector (when enabled) 106 | # whether or not to capture HTTP params. When true, frameworks can 107 | # exclude HTTP parameters from being captured. 108 | # Rails: the RoR filter_parameter_logging excludes parameters 109 | # Java: create a config setting called "ignored_params" and set it to 110 | # a comma separated list of HTTP parameter names. 111 | # ex: ignored_params: credit_card, ssn, password 112 | capture_params: false 113 | 114 | # Transaction tracer captures deep information about slow 115 | # transactions and sends this to the New Relic service once a 116 | # minute. Included in the transaction is the exact call sequence of 117 | # the transactions including any SQL statements issued. 118 | transaction_tracer: 119 | 120 | # Transaction tracer is enabled by default. Set this to false to 121 | # turn it off. This feature is only available at the Professional 122 | # and above product levels. 123 | enabled: true 124 | 125 | # Threshold in seconds for when to collect a transaction 126 | # trace. When the response time of a controller action exceeds 127 | # this threshold, a transaction trace will be recorded and sent to 128 | # New Relic. Valid values are any float value, or (default) "apdex_f", 129 | # which will use the threshold for an dissatisfying Apdex 130 | # controller action - four times the Apdex T value. 131 | transaction_threshold: apdex_f 132 | 133 | # When transaction tracer is on, SQL statements can optionally be 134 | # recorded. The recorder has three modes, "off" which sends no 135 | # SQL, "raw" which sends the SQL statement in its original form, 136 | # and "obfuscated", which strips out numeric and string literals. 137 | record_sql: obfuscated 138 | 139 | # Threshold in seconds for when to collect stack trace for a SQL 140 | # call. In other words, when SQL statements exceed this threshold, 141 | # then capture and send to New Relic the current stack trace. This is 142 | # helpful for pinpointing where long SQL calls originate from. 143 | stack_trace_threshold: 0.500 144 | 145 | # Determines whether the agent will capture query plans for slow 146 | # SQL queries. Only supported in mysql and postgres. Should be 147 | # set to false when using other adapters. 148 | # explain_enabled: true 149 | 150 | # Threshold for query execution time below which query plans will 151 | # not be captured. Relevant only when `explain_enabled` is true. 152 | # explain_threshold: 0.5 153 | 154 | # Error collector captures information about uncaught exceptions and 155 | # sends them to New Relic for viewing 156 | error_collector: 157 | 158 | # Error collector is enabled by default. Set this to false to turn 159 | # it off. This feature is only available at the Professional and above 160 | # product levels. 161 | enabled: true 162 | 163 | # Rails Only - tells error collector whether or not to capture a 164 | # source snippet around the place of the error when errors are View 165 | # related. 166 | capture_source: true 167 | 168 | # To stop specific errors from reporting to New Relic, set this property 169 | # to comma-separated values. Default is to ignore routing errors, 170 | # which are how 404's get triggered. 171 | ignore_errors: "ActionController::RoutingError,Sinatra::NotFound" 172 | 173 | # If you're interested in capturing memcache keys as though they 174 | # were SQL uncomment this flag. Note that this does increase 175 | # overhead slightly on every memcached call, and can have security 176 | # implications if your memcached keys are sensitive 177 | # capture_memcache_keys: true 178 | 179 | # Application Environments 180 | # ------------------------------------------ 181 | # Environment-specific settings are in this section. 182 | # For Rails applications, RAILS_ENV is used to determine the environment. 183 | # For Java applications, pass -Dnewrelic.environment to set 184 | # the environment. 185 | 186 | # NOTE if your application has other named environments, you should 187 | # provide newrelic configuration settings for these environments here. 188 | 189 | development: 190 | <<: *default_settings 191 | # Turn off communication to New Relic service in development mode (also 192 | # 'enabled'). 193 | # NOTE: for initial evaluation purposes, you may want to temporarily 194 | # turn the agent on in development mode. 195 | monitor_mode: false 196 | 197 | # Rails Only - when running in Developer Mode, the New Relic Agent will 198 | # present performance information on the last 100 transactions you have 199 | # executed since starting the mongrel. 200 | # NOTE: There is substantial overhead when running in developer mode. 201 | # Do not use for production or load testing. 202 | developer_mode: true 203 | 204 | # Enable textmate links 205 | # textmate: true 206 | 207 | test: 208 | <<: *default_settings 209 | # It almost never makes sense to turn on the agent when running 210 | # unit, functional or integration tests or the like. 211 | monitor_mode: false 212 | 213 | # Turn on the agent in production for 24x7 monitoring. NewRelic 214 | # testing shows an average performance impact of < 5 ms per 215 | # transaction, you can leave this on all the time without 216 | # incurring any user-visible performance degradation. 217 | production: 218 | <<: *default_settings 219 | monitor_mode: true 220 | 221 | # Many applications have a staging environment which behaves 222 | # identically to production. Support for that environment is provided 223 | # here. By default, the staging environment has the agent turned on. 224 | staging: 225 | <<: *default_settings 226 | monitor_mode: true 227 | app_name: <%= ENV["NEW_RELIC_APP_NAME"] %> (Staging) -------------------------------------------------------------------------------- /spec/lib/dollar_amounts_processor_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../dollar_amounts_processor_spec_helper', __FILE__) 2 | 3 | describe DollarAmountsProcessor do 4 | 5 | describe "requested test data" do 6 | it "replaces words with numbers in sample text #01" do 7 | expect(subject.process(<<-EOIN 8 | Your food stamp balance is six dollars and twenty five cents. Your cash account 9 | balance is eleven dollars and sixty nine cents. As a reminder. By saving the 10 | receipt from your last purchase and or your last cash purchase or cashback 11 | Prinz action. You will always have your. 12 | EOIN 13 | )).to eq_text <<-EOOUT 14 | Your food stamp balance is $6.25. Your cash account 15 | balance is $11.69. As a reminder. By saving the 16 | receipt from your last purchase and or your last cash purchase or cashback 17 | Prinz action. You will always have your. 18 | EOOUT 19 | end 20 | 21 | it "replaces words with numbers in sample text #02" do 22 | expect(subject.process(<<-EOIN 23 | Your snap balance is four hundred twenty six dollars. Your cash balance is zero 24 | dollars. As a reminder by saving the receipt from your last purchase you will 25 | know your current balance. Remember you can also access your account 26 | information online at. 27 | EOIN 28 | )).to eq_text <<-EOOUT 29 | Your snap balance is $426.00. Your cash balance is $0.00. 30 | As a reminder by saving the receipt from your last purchase you will 31 | know your current balance. Remember you can also access your account 32 | information online at. 33 | EOOUT 34 | end 35 | 36 | it "replaces words with numbers in sample text #03" do 37 | expect(subject.process(<<-EOIN 38 | One moment please. OK. I've pulled up your account information. Your food stamp 39 | balance is seven hundred sixty six dollars and thirty seven cents. You are 40 | eligible to enroll in a free service called my own. 41 | EOIN 42 | )).to eq_text <<-EOOUT 43 | One moment please. OK. I've pulled up your account information. Your food stamp 44 | balance is $766.37. You are 45 | eligible to enroll in a free service called my own. 46 | EOOUT 47 | end 48 | 49 | it "replaces words with numbers in sample text #04" do 50 | expect(subject.process(<<-EOIN 51 | One moment please. OK. I've pulled up your account information. Your food stamp 52 | balance is seven hundred sixty six dollars and thirty seven cents. You are 53 | eligible to enroll in a free service called my alerts. 54 | EOIN 55 | )).to eq_text <<-EOOUT 56 | One moment please. OK. I've pulled up your account information. Your food stamp 57 | balance is $766.37. You are 58 | eligible to enroll in a free service called my alerts. 59 | EOOUT 60 | end 61 | 62 | it "replaces words with numbers in sample text #05" do 63 | expect(subject.process(<<-EOIN 64 | Balance is one hundred seventy one dollars and sixty eight cents. Your cash 65 | account balance is zero dollars and ninety cents. As a reminder. By saving the 66 | receipt from your last purchase and or your last cash purchase or cashback 67 | transaction. You will always have you. 68 | EOIN 69 | )).to eq_text <<-EOOUT 70 | Balance is $171.68. Your cash 71 | account balance is $0.90. As a reminder. By saving the 72 | receipt from your last purchase and or your last cash purchase or cashback 73 | transaction. You will always have you. 74 | EOOUT 75 | end 76 | 77 | it "replaces words with numbers in sample text #06" do 78 | expect(subject.process(<<-EOIN 79 | Balance is four hundred one dollars and twenty three cents. Your cash account 80 | balance is two dollars and fifty one cents. As a reminder. By saving the 81 | receipt from your last purchase and or your last cash purchase or cashback 82 | transaction. You will always have your current. 83 | EOIN 84 | )).to eq_text <<-EOOUT 85 | Balance is $401.23. Your cash account 86 | balance is $2.51. As a reminder. By saving the 87 | receipt from your last purchase and or your last cash purchase or cashback 88 | transaction. You will always have your current. 89 | EOOUT 90 | end 91 | 92 | it "replaces words with numbers in sample text #07" do 93 | expect(subject.process(<<-EOIN 94 | Snap balance is three hundred fourteen dollars. Your cash balance is zero 95 | dollars. As a reminder by saving the receipt from your last purchase you'll 96 | know your current balance. Remember you can also access your account 97 | information online at W W. 98 | EOIN 99 | )).to eq_text <<-EOOUT 100 | Snap balance is $314.00. Your cash balance is $0.00. 101 | As a reminder by saving the receipt from your last purchase you'll 102 | know your current balance. Remember you can also access your account 103 | information online at W W. 104 | EOOUT 105 | end 106 | 107 | it "replaces words with numbers in sample text #08 (bonus ;)" do 108 | expect(subject.process(<<-EOIN 109 | Balance is the euro. Dollars. Your cash account balance is five hundred forty 110 | one dollars as a reminder. By saving the receipt from your last purchase and or 111 | your last cash purchase or cashback transaction. You will always have your 112 | current balance. Some A.T.M.'s will also print your balance on a cash with the. 113 | EOIN 114 | )).to eq_text <<-EOOUT 115 | Balance is $0.00. Your cash account balance is $541.00 116 | as a reminder. By saving the receipt from your last purchase and or 117 | your last cash purchase or cashback transaction. You will always have your 118 | current balance. Some A.T.M.'s will also print your balance on a cash with the. 119 | EOOUT 120 | end 121 | 122 | it "replaces words with numbers in sample text #09" do 123 | expect(subject.process(<<-EOIN 124 | Balance is twenty two dollars and eight cents. Your cash account balance 125 | is zero dollars and sixty eight cents. As a reminder... 126 | EOIN 127 | )).to eq_text <<-EOOUT 128 | Balance is $22.08. Your cash account balance 129 | is $0.68. As a reminder... 130 | EOOUT 131 | end 132 | 133 | it "replaces words with numbers in sample text #10" do 134 | expect(subject.process(<<-EOIN 135 | One moment please. OK. I've pulled up your account information. 136 | Your food stamp balance is the row. Dollars and forty two cents. You are 137 | EOIN 138 | )).to eq_text <<-EOOUT 139 | One moment please. OK. I've pulled up your account information. 140 | Your food stamp balance is $0.42. You are 141 | EOOUT 142 | end 143 | 144 | it "replaces words with numbers in euro case #2" do 145 | expect(subject.process( 146 | "That balance is one hundred seventy one dollars and sixty eight cents. Your cash account balance is the euro. Dollars and ninety cents. As a reminder. By saving the receipt from your last purchase and or your last cash purchase or cashback transaction. You will always have." 147 | )).to eq_text( 148 | "That balance is $171.68. Your cash account balance is $0.90. As a reminder. By saving the receipt from your last purchase and or your last cash purchase or cashback transaction. You will always have." 149 | ) 150 | end 151 | 152 | it "handles 'the row' zero problem" do 153 | expect(subject.process( 154 | "One moment please. OK. I've pulled up your account information. Your food stamp balance is the row. Dollars and forty two cents. You are" 155 | )).to eq_text( 156 | "One moment please. OK. I've pulled up your account information. Your food stamp balance is $0.42. You are" 157 | ) 158 | end 159 | 160 | it "handles case #9" do 161 | expect(subject.process( 162 | "They were snapped balances. Ten dollars and twenty two cents. Your cash balance is one dollar to repeat your account balance. Press one. To hear your last ten transactions on your card. Press two to change European press three. To report." 163 | )).to eq_text( 164 | "They were snapped balances. $10.22. Your cash balance is $1.00 to repeat your account balance. Press one. To hear your last ten transactions on your card. Press two to change European press three. To report." 165 | ) 166 | end 167 | 168 | it "handles case #10" do 169 | expect(subject.process( 170 | "Balance is twenty two dollars and eight cents. Your cash account balance is zero dollars and sixty eight cents. As a reminder. By saving the receipt from your last purchase and or your last cash purchase or cash back Prinz action. You will always have your current balance." 171 | )).to eq_text( 172 | "Balance is $22.08. Your cash account balance is $0.68. As a reminder. By saving the receipt from your last purchase and or your last cash purchase or cash back Prinz action. You will always have your current balance." 173 | ) 174 | end 175 | 176 | it "handles case #11" do 177 | expect(subject.process( 178 | "Step. Balance is nineteen dollars and five cents. Your cash account balance is eight dollars and thirty one cents. As a reminder. By saving the receipt from your last purchase and or your last cash purchase or cashback transaction. You will always have your current balance." 179 | )).to eq_text( 180 | "Step. Balance is $19.05. Your cash account balance is $8.31. As a reminder. By saving the receipt from your last purchase and or your last cash purchase or cashback transaction. You will always have your current balance." 181 | ) 182 | end 183 | 184 | it 'handles this case' do 185 | expect(subject.process( 186 | "Balance is the euro. Dollars and seven cents. Your cash account balance is sixty five dollars and thirty nine cents. As a reminder. By saving the receipt from your last purchase and or your last cash purchase or cashback transaction. You will always have your current balance." 187 | )).to eq_text( 188 | "Balance is $0.07. Your cash account balance is $65.39. As a reminder. By saving the receipt from your last purchase and or your last cash purchase or cashback transaction. You will always have your current balance." 189 | ) 190 | end 191 | 192 | it "handles some other" do 193 | expect(subject.process( 194 | "Nap balances. Six hundred forty nine dollars. Your cash balance is zero dollars. As a reminder by saving the receipt from your last purchase you will know your current balance. Remember you can also access your account information on." 195 | )).to eq_text( 196 | "Nap balances. $649.00. Your cash balance is $0.00. As a reminder by saving the receipt from your last purchase you will know your current balance. Remember you can also access your account information on." 197 | ) 198 | end 199 | 200 | it "handles digits #351 text 1" do 201 | expect(subject.process( 202 | "Balance is 1 4 8 dollars and 9 2 cents. Your cash account balance is the euro. Dollars as a reminder. By saving the receipt from your last purchase and or your lastcash purchase or cashback transaction. You will always have your current balance. Some A.T.O.." 203 | )).to eq_text( 204 | "Balance is $148.92. Your cash account balance is $0.00 as a reminder. By saving the receipt from your last purchase and or your lastcash purchase or cashback transaction. You will always have your current balance. Some A.T.O.." 205 | ) 206 | end 207 | 208 | it "handles digits #351 text 2" do 209 | expect(subject.process( 210 | "Step balance. Is 2 7 1 dollars and 8 7 cents. Your cash account balance. Is the euro. Dollars. As a reminder. By saving the receipt from your last purchase. And oryour last. Cash purchase or cashback transaction. You will always have your current balance." 211 | )).to eq_text( 212 | "Step balance. Is $271.87. Your cash account balance. Is $0.00. As a reminder. By saving the receipt from your last purchase. And oryour last. Cash purchase or cashback transaction. You will always have your current balance." 213 | ) 214 | end 215 | 216 | it "handles digits #351 text 3" do 217 | expect(subject.process( 218 | "moment please. OK. I've pulled up your account information. Your food stamp balance is 0 dollars and 2 0 cents. You are eligible to enroll in a free service called myalerts. With my." 219 | )).to eq_text( 220 | "moment please. OK. I've pulled up your account information. Your food stamp balance is $0.20. You are eligible to enroll in a free service called myalerts. With my." 221 | ) 222 | end 223 | 224 | it "handles digits #351 text 4" do 225 | expect(subject.process( 226 | "Balance is 2 6 dollars. And boy 6 cents. Your cash account balance is 0 dollars as a reminder. By saving the receipt from your last purchase and or your last cashpurchase or cashback transaction. You will always have your current balance. Some A.T.M.'s will also." 227 | )).to eq_text( 228 | "Balance is $26.46. Your cash account balance is $0.00 as a reminder. By saving the receipt from your last purchase and or your last cashpurchase or cashback transaction. You will always have your current balance. Some A.T.M.'s will also." 229 | ) 230 | end 231 | 232 | end 233 | end 234 | -------------------------------------------------------------------------------- /spec/app_spec.rb: -------------------------------------------------------------------------------- 1 | require 'app_spec_helper' 2 | 3 | describe EbtBalanceSmsApp, :type => :feature do 4 | describe 'initial text' do 5 | let(:texter_number) { "+12223334444" } 6 | let(:inbound_twilio_number) { "+15556667777" } 7 | let(:fake_twilio) { double("FakeTwilioService", :make_call => 'made call', :send_text => 'sent text') } 8 | let(:to_state) { 'CA' } 9 | let(:fake_message_generator) { double("MessageGenerator", :thanks_please_wait => "fake thanks please wait msg") } 10 | 11 | context 'with valid EBT number' do 12 | let(:ebt_number) { "1111222233334444" } 13 | let(:fake_state_handler) { double('FakeStateHandler', :phone_number => 'fake_state_phone_number', :button_sequence => "fake_button_sequence", :extract_valid_ebt_number_from_text => ebt_number) } 14 | 15 | before do 16 | allow(TwilioService).to receive(:new).and_return(fake_twilio) 17 | allow(MessageGenerator).to receive(:new).and_return(fake_message_generator) 18 | allow(StateHandler).to receive(:for).with(to_state).and_return(fake_state_handler) 19 | allow(SecureRandom).to receive(:hex).and_return("fakehexvalue") 20 | post '/', { "Body" => ebt_number, "From" => texter_number, "To" => inbound_twilio_number, "ToState" => to_state } 21 | end 22 | 23 | it 'initializes a new state handler' do 24 | expect(StateHandler).to have_received(:for).with(to_state) 25 | end 26 | 27 | it 'initiates an outbound Twilio call to EBT line with correct details' do 28 | expect(fake_twilio).to have_received(:make_call).with( 29 | url: "http://example.org/get_balance?phone_number=#{texter_number}&twilio_phone_number=#{inbound_twilio_number}&state=#{to_state}&ebt_number=#{ebt_number}&balance_check_id=fakehexvalue", 30 | to: fake_state_handler.phone_number, 31 | from: inbound_twilio_number, 32 | method: 'GET' 33 | ) 34 | end 35 | 36 | it 'sends a text with outage message' do 37 | expect(fake_twilio).to have_received(:send_text).with( 38 | to: texter_number, 39 | from: inbound_twilio_number, 40 | body: "fake thanks please wait msg" 41 | ) 42 | end 43 | 44 | it 'responds with 200 status' do 45 | expect(last_response.status).to eq(200) 46 | end 47 | end 48 | 49 | context 'with INVALID EBT number' do 50 | let(:invalid_ebt_number) { "111122223333" } 51 | let(:fake_state_handler) { double('FakeStateHandler', :phone_number => 'fake_state_phone_number', :button_sequence => "fake_button_sequence", :extract_valid_ebt_number_from_text => :invalid_number, :allowed_number_of_ebt_card_digits => [14] ) } 52 | 53 | before do 54 | allow(TwilioService).to receive(:new).and_return(fake_twilio) 55 | allow(StateHandler).to receive(:for).with(to_state).and_return(fake_state_handler) 56 | post '/', { "Body" => invalid_ebt_number, "From" => texter_number, "To" => inbound_twilio_number, "ToState" => to_state } 57 | end 58 | 59 | it 'asks the state handler for the EBT card number of digits (to produce sorry msg)' do 60 | expect(fake_state_handler).to have_received(:allowed_number_of_ebt_card_digits) 61 | end 62 | 63 | it 'sends a text to the user with error message' do 64 | expect(fake_twilio).to have_received(:send_text).with( 65 | to: texter_number, 66 | from: inbound_twilio_number, 67 | body: "Sorry! That number doesn't look right. Please reply with your 14-digit EBT card number or ABOUT for more information." 68 | ) 69 | end 70 | 71 | it 'responds with 200 status' do 72 | expect(last_response.status).to eq(200) 73 | end 74 | end 75 | 76 | context 'asking for more info (about)' do 77 | let(:fake_state_handler) { double('FakeStateHandler', :phone_number => 'fake_state_phone_number', :button_sequence => "fake_button_sequence", :extract_valid_ebt_number_from_text => :invalid_number, :allowed_number_of_ebt_card_digits => [14] ) } 78 | let(:more_info_content) { "This is a free text service by non-profit Code for America for checking your EBT balance (standard msg rates apply). For more info go to http://c4a.me/balance" } 79 | 80 | before do 81 | allow(TwilioService).to receive(:new).and_return(fake_twilio) 82 | allow(StateHandler).to receive(:for).with(to_state).and_return(fake_state_handler) 83 | end 84 | 85 | context 'with all caps' do 86 | let(:body) { "ABOUT" } 87 | 88 | before do 89 | post '/', { "Body" => body, "From" => texter_number, "To" => inbound_twilio_number, "ToState" => to_state } 90 | end 91 | 92 | it 'sends a text to the user with more info' do 93 | expect(fake_twilio).to have_received(:send_text).with( 94 | to: texter_number, 95 | from: inbound_twilio_number, 96 | body: more_info_content 97 | ) 98 | end 99 | 100 | it 'responds with 200 status' do 101 | expect(last_response.status).to eq(200) 102 | end 103 | end 104 | 105 | context 'with lower case' do 106 | let(:body) { "about" } 107 | 108 | before do 109 | post '/', { "Body" => body, "From" => texter_number, "To" => inbound_twilio_number, "ToState" => to_state } 110 | end 111 | 112 | it 'sends a text to the user with more info' do 113 | expect(fake_twilio).to have_received(:send_text).with( 114 | to: texter_number, 115 | from: inbound_twilio_number, 116 | body: more_info_content 117 | ) 118 | end 119 | 120 | it 'responds with 200 status' do 121 | expect(last_response.status).to eq(200) 122 | end 123 | end 124 | 125 | context 'with camel case' do 126 | let(:body) { "About" } 127 | 128 | before do 129 | post '/', { "Body" => body, "From" => texter_number, "To" => inbound_twilio_number, "ToState" => to_state } 130 | end 131 | 132 | it 'sends a text to the user with more info' do 133 | expect(fake_twilio).to have_received(:send_text).with( 134 | to: texter_number, 135 | from: inbound_twilio_number, 136 | body: more_info_content 137 | ) 138 | end 139 | 140 | it 'responds with 200 status' do 141 | expect(last_response.status).to eq(200) 142 | end 143 | end 144 | 145 | context 'with about embedded inside another string' do 146 | let(:body) { "akjhsasfhaboutaskjh ashjd PHEEa23" } 147 | 148 | before do 149 | post '/', { "Body" => body, "From" => texter_number, "To" => inbound_twilio_number, "ToState" => to_state } 150 | end 151 | 152 | it 'sends a text to the user with more info' do 153 | expect(fake_twilio).to have_received(:send_text).with( 154 | to: texter_number, 155 | from: inbound_twilio_number, 156 | body: more_info_content 157 | ) 158 | end 159 | 160 | it 'responds with 200 status' do 161 | expect(last_response.status).to eq(200) 162 | end 163 | end 164 | end 165 | 166 | context 'with blocked phone number' do 167 | let(:fake_state_handler) { double('FakeStateHandler', :phone_number => 'fake_state_phone_number', :button_sequence => "fake_button_sequence", :extract_valid_ebt_number_from_text => :invalid_number, :allowed_number_of_ebt_card_digits => [14] ) } 168 | let(:fake_twilio_with_blacklist_raise) { double("FakeTwilioService") } 169 | 170 | before do 171 | allow(fake_twilio_with_blacklist_raise).to receive(:send_text).and_raise(Twilio::REST::RequestError.new("The message From/To pair violates a blacklist rule.")) 172 | allow(TwilioService).to receive(:new).and_return(fake_twilio_with_blacklist_raise) 173 | allow(StateHandler).to receive(:for).with(to_state).and_return(fake_state_handler) 174 | end 175 | 176 | context 'with an EBT # that passes validation in the body' do 177 | before do 178 | post '/', { "Body" => "11112222333344", "From" => texter_number, "To" => inbound_twilio_number, "ToState" => to_state } 179 | end 180 | 181 | it 'does NOT initiate a call via Twilio' do 182 | expect(fake_twilio).to_not have_received(:make_call) 183 | end 184 | 185 | it 'does not blow up (ie, it responds with 200 status)' do 186 | expect(last_response.status).to eq(200) 187 | end 188 | end 189 | 190 | context 'with text in the body' do 191 | before do 192 | post '/', { "Body" => "Stop", "From" => texter_number, "To" => inbound_twilio_number, "ToState" => to_state } 193 | end 194 | 195 | it 'does NOT initiate a call via Twilio' do 196 | expect(fake_twilio).to_not have_received(:make_call) 197 | end 198 | 199 | it 'does not blow up (ie, it responds with 200 status)' do 200 | expect(last_response.status).to eq(200) 201 | end 202 | end 203 | end 204 | 205 | context 'using Spanish-language Twilio phone number' do 206 | let(:ebt_number) { "1111222233334444" } 207 | let(:spanish_twilio_number) { "+19998887777" } 208 | let(:inbound_twilio_number) { spanish_twilio_number } 209 | let(:fake_state_handler) { double('FakeStateHandler', :phone_number => 'fake_state_phone_number', :button_sequence => "fake_button_sequence", :extract_valid_ebt_number_from_text => ebt_number ) } 210 | let(:spanish_message_generator) { double('SpanishMessageGenerator', :thanks_please_wait => 'spanish thankspleasewait') } 211 | 212 | before do 213 | allow(TwilioService).to receive(:new).and_return(fake_twilio) 214 | allow(StateHandler).to receive(:for).with(to_state).and_return(fake_state_handler) 215 | allow(MessageGenerator).to receive(:new).with(:spanish).and_return(spanish_message_generator) 216 | post '/', { "Body" => ebt_number, "From" => texter_number, "To" => inbound_twilio_number, "ToState" => to_state } 217 | end 218 | 219 | it 'sends a text IN SPANISH to the user telling them wait time' do 220 | expect(fake_twilio).to have_received(:send_text).with( 221 | to: texter_number, 222 | from: inbound_twilio_number, 223 | body: spanish_message_generator.thanks_please_wait 224 | ) 225 | end 226 | 227 | it 'responds with 200 status' do 228 | expect(last_response.status).to eq(200) 229 | end 230 | end 231 | end 232 | 233 | describe 'GET /get_balance' do 234 | let(:texter_number) { "+12223334444" } 235 | let(:ebt_number) { "5555444433332222" } 236 | let(:inbound_twilio_number) { "+15556667777" } 237 | let(:state) { 'CA' } 238 | let(:ebt_number) { "1111222233334444" } 239 | let(:fake_state_handler) { double('FakeStateHandler', :button_sequence => "fake_button_sequence", :max_message_length => 22) } 240 | 241 | before do 242 | allow(StateHandler).to receive(:for).and_return(fake_state_handler) 243 | get "/get_balance?phone_number=#{texter_number}&twilio_phone_number=#{inbound_twilio_number}&state=#{state}&ebt_number=#{ebt_number}&balance_check_id=fakehexvalue" 244 | parsed_response = Nokogiri::XML(last_response.body) 245 | @play_digits = parsed_response.children.children[0].get_attribute("digits") 246 | @callback_url = parsed_response.children.children[1].get_attribute("transcribeCallback") 247 | @timeout = parsed_response.children.children[1].get_attribute("timeout") 248 | @maxlength = parsed_response.children.children[1].get_attribute("maxLength") 249 | end 250 | 251 | it "passes the EBT number to the state handler's button sequence method" do 252 | expect(fake_state_handler).to have_received(:button_sequence).with(ebt_number) 253 | end 254 | 255 | it 'plays the button sequence for the state' do 256 | expect(@play_digits).to eq('fake_button_sequence') 257 | end 258 | 259 | it 'responds with callback to correct URL (ie, correct phone number)' do 260 | expect(@callback_url).to eq("http://example.org/CA/12223334444/15556667777/fakehexvalue/send_balance") 261 | end 262 | 263 | it 'sets the timeout for the recording to 10 seconds' do 264 | expect(@timeout).to eq('10') 265 | end 266 | 267 | it 'has max recording length set correctly' do 268 | expect(@maxlength).to eq("22") 269 | end 270 | 271 | it 'responds with 200 status' do 272 | expect(last_response.status).to eq(200) 273 | end 274 | end 275 | 276 | describe 'sending the balance to user' do 277 | let(:to_phone_number) { "19998887777" } 278 | let(:twilio_number) { "+15556667777" } 279 | let(:fake_twilio) { double("FakeTwilioService", :send_text => 'sent text') } 280 | let(:state) { 'CA' } 281 | 282 | before do 283 | allow(TwilioService).to receive(:new).and_return(fake_twilio) 284 | end 285 | 286 | context 'when EBT number is valid' do 287 | let(:transcription_text) { 'fake raw transcription containing balance' } 288 | let(:handler_balance_response) { 'Hi! Your balance is...' } 289 | let(:fake_state_handler) { double('FakeStateHandler', :transcribe_balance_response => handler_balance_response) } 290 | 291 | before do 292 | allow(StateHandler).to receive(:for).with(state).and_return(fake_state_handler) 293 | post "/#{state}/#{to_phone_number}/#{twilio_number}/fakehexvalue/send_balance", { "TranscriptionText" => transcription_text } 294 | end 295 | 296 | it 'sends transcription text and language to the handler' do 297 | expect(fake_state_handler).to have_received(:transcribe_balance_response).with(transcription_text, :english) 298 | end 299 | 300 | it 'sends the correct amounts to user' do 301 | expect(fake_twilio).to have_received(:send_text).with( 302 | to: to_phone_number, 303 | from: twilio_number, 304 | body: handler_balance_response 305 | ) 306 | end 307 | 308 | it 'returns status 200' do 309 | expect(last_response.status).to eq(200) 310 | end 311 | end 312 | 313 | context 'when EBT number is NOT valid' do 314 | let(:handler_balance_response) { 'Sorry...' } 315 | let(:fake_state_handler) { double('FakeStateHandler', :transcribe_balance_response => handler_balance_response) } 316 | 317 | before do 318 | allow(StateHandler).to receive(:for).with(state).and_return(fake_state_handler) 319 | post "/#{state}/#{to_phone_number}/#{twilio_number}/fakehexvalue/send_balance", { "TranscriptionText" => 'fake raw transcription for EBT number not found' } 320 | end 321 | 322 | it 'sends the user an error message' do 323 | expect(fake_twilio).to have_received(:send_text).with( 324 | to: to_phone_number, 325 | from: twilio_number, 326 | body: handler_balance_response 327 | ) 328 | end 329 | 330 | it 'returns status 200' do 331 | expect(last_response.status).to eq(200) 332 | end 333 | end 334 | end 335 | 336 | describe 'POST /get_balance' do 337 | before do 338 | post '/get_balance' 339 | end 340 | 341 | it 'responds with 200 status' do 342 | expect(last_response.status).to eq(200) 343 | end 344 | 345 | it 'responds with valid Twiml that does nothing' do 346 | desired_response = < 348 | 349 | 350 | EOF 351 | expect(last_response.body).to eq(desired_response) 352 | end 353 | end 354 | 355 | describe 'inbound voice call' do 356 | let(:caller_number) { "+12223334444" } 357 | let(:inbound_twilio_number) { "+14156667777" } 358 | let(:to_state) { 'CA' } 359 | let(:fake_state_phone_number) { '+18882223333' } 360 | let(:fake_state_handler) { double('FakeStateHandler', :phone_number => fake_state_phone_number) } 361 | let(:fake_twilio) { double("FakeTwilioService", :send_text => 'sent text') } 362 | let(:fake_message_generator) { double('MessageGenerator', :inbound_voice_call_text_message => 'voice call text message', :call_in_voice_file_url => 'fakeurl') } 363 | 364 | before do 365 | allow(TwilioService).to receive(:new).and_return(fake_twilio) 366 | allow(StateHandler).to receive(:for).with(to_state).and_return(fake_state_handler) 367 | allow(MessageGenerator).to receive(:new).and_return(fake_message_generator) 368 | post '/voice_call', { "From" => caller_number, "To" => inbound_twilio_number, "ToState" => to_state } 369 | end 370 | 371 | it 'responds with 200 status' do 372 | expect(last_response.status).to eq(200) 373 | end 374 | 375 | it 'does NOT send an outbound text to the number' do 376 | expect(fake_twilio).to_not have_received(:send_text) 377 | end 378 | 379 | it 'plays welcome message to caller and allows them to go to state line' do 380 | desired_response = < 382 | 383 | 384 | #{fake_message_generator.call_in_voice_file_url} 385 | 386 | http://twimlets.com/forward?PhoneNumber=#{fake_state_phone_number} 387 | 388 | EOF 389 | expect(last_response.body).to eq(desired_response) 390 | end 391 | end 392 | 393 | describe 'welcome text message' do 394 | let(:body) { "Hi there! Reply to this message with your EBT card number and we'll check your balance for you. For more info, text ABOUT." } 395 | let(:fake_twilio) { double("FakeTwilioService", :send_text => 'sent text') } 396 | let(:inbound_twilio_number) { "+15556667777" } 397 | let(:invalid_number_message_text) { "Sorry! That number doesn't look right. Please go back and try again." } 398 | 399 | before(:each) do 400 | allow(TwilioService).to receive(:new).and_return(fake_twilio) 401 | post '/welcome', { "inbound_twilio_number" => inbound_twilio_number, "texter_phone_number" => texter_phone_number } 402 | end 403 | 404 | context 'with a valid phone number' do 405 | let(:texter_phone_number) { "+12223334444" } 406 | 407 | it 'sends a text to the user with instructions' do 408 | expect(fake_twilio).to have_received(:send_text).with( 409 | to: texter_phone_number, 410 | from: inbound_twilio_number, 411 | body: body 412 | ) 413 | end 414 | 415 | it 'responds with 200 status' do 416 | expect(last_response.status).to eq(200) 417 | end 418 | end 419 | 420 | context "with a valid (with formatting) phone number" do 421 | let(:texter_phone_number) { "(510) 111-2222" } 422 | 423 | it 'sends a text' do 424 | expect(fake_twilio).to have_received(:send_text).with( 425 | to: '+15101112222', 426 | from: inbound_twilio_number, 427 | body: body 428 | ) 429 | end 430 | 431 | it 'responds with 200 status' do 432 | expect(last_response.status).to eq(200) 433 | end 434 | end 435 | 436 | context "with garbage input" do 437 | let(:texter_phone_number) { "asfljhasgkjshgkj" } 438 | 439 | it 'does NOT send a text' do 440 | expect(fake_twilio).to_not have_received(:send_text) 441 | end 442 | 443 | it 'responds with 200 status' do 444 | expect(last_response.status).to eq(200) 445 | end 446 | 447 | it 'gives error message telling you to try again' do 448 | expect(last_response.body).to include(invalid_number_message_text) 449 | end 450 | end 451 | 452 | context "with an invalid (too long) phone number" do 453 | let(:texter_phone_number) { "41522233334" } 454 | 455 | it 'does NOT send a text' do 456 | expect(fake_twilio).to_not have_received(:send_text) 457 | end 458 | 459 | it 'responds with 200 status' do 460 | expect(last_response.status).to eq(200) 461 | end 462 | 463 | it 'gives error message telling you to try again' do 464 | expect(last_response.body).to include(invalid_number_message_text) 465 | end 466 | end 467 | 468 | context "with an invalid (too short) phone number" do 469 | let(:texter_phone_number) { "415222333" } 470 | 471 | it 'does NOT send a text' do 472 | expect(fake_twilio).to_not have_received(:send_text) 473 | end 474 | 475 | it 'responds with 200 status' do 476 | expect(last_response.status).to eq(200) 477 | end 478 | 479 | it 'gives error message telling you to try again' do 480 | expect(last_response.body).to include(invalid_number_message_text) 481 | end 482 | end 483 | 484 | context "with a user inputting one of the app's Twilio phone numbers" do 485 | let(:texter_phone_number) { "555 666 7777" } 486 | 487 | it 'does NOT send a text' do 488 | expect(fake_twilio).to_not have_received(:send_text) 489 | end 490 | 491 | it 'responds with 200 status' do 492 | expect(last_response.status).to eq(200) 493 | end 494 | 495 | it 'gives error message telling you to try again' do 496 | expect(last_response.body).to include(invalid_number_message_text) 497 | end 498 | end 499 | 500 | context '7 digit phone number' do 501 | let(:texter_phone_number) { "2223333" } 502 | 503 | it 'does NOT send a text' do 504 | expect(fake_twilio).to_not have_received(:send_text) 505 | end 506 | 507 | it 'responds with 200 status' do 508 | expect(last_response.status).to eq(200) 509 | end 510 | 511 | it 'gives error message telling you to try again' do 512 | expect(last_response.body).to include(invalid_number_message_text) 513 | end 514 | end 515 | end 516 | 517 | describe 'status monitoring' do 518 | context 'when balance checks working' do 519 | before do 520 | @time_for_test = Time.parse("Wed, 29 Apr 2015 22:42:07 GMT") 521 | Timecop.freeze(@time_for_test) 522 | VCR.use_cassette('messages-for-status-check-system-working') do 523 | get '/.well-known/status' 524 | end 525 | @parsed_response = JSON.parse(last_response.body) 526 | end 527 | 528 | it 'responds with 200 status' do 529 | expect(last_response.status).to eq(200) 530 | end 531 | 532 | it 'responds with json' do 533 | expect(last_response.content_type).to eq('application/json') 534 | end 535 | 536 | it 'reports the status as ok' do 537 | expect(@parsed_response['status']).to eq('ok') 538 | end 539 | 540 | it 'reports the time of the status check as an integer' do 541 | expect(@parsed_response['updated']).to eq(@time_for_test.to_i) 542 | end 543 | 544 | it 'reports Twilio as the only dependency' do 545 | expect(@parsed_response['dependencies']).to include('twilio') 546 | end 547 | end 548 | 549 | context 'when balance check responses are delayed/non-responsive' do 550 | before do 551 | time_for_test = Time.parse("Wed, 29 Apr 2015 22:42:07 GMT") 552 | Timecop.freeze(time_for_test) 553 | VCR.use_cassette('messages-for-status-check-system-down') do 554 | get '/.well-known/status' 555 | end 556 | @parsed_response = JSON.parse(last_response.body) 557 | end 558 | 559 | it 'reports a verbose description of the problem' do 560 | expect(@parsed_response['status']).to eq("Missing balance response: User with number ending '34444' did not receive a response within 5 minutes to their request at 2015-04-29 15:34:37 Pacific. 'Thanks' message SID: fakesid2") 561 | end 562 | 563 | it 'responds with 200 status' do 564 | expect(last_response.status).to eq(200) 565 | end 566 | end 567 | end 568 | end 569 | -------------------------------------------------------------------------------- /spec/lib/state_handler_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require File.expand_path('../../../lib/state_handler', __FILE__) 3 | 4 | describe StateHandler do 5 | describe '::for' do 6 | context 'given a state with an existing handler' do 7 | it "returns the state's handler module" do 8 | handler = StateHandler.for('CA') 9 | expect(handler).to be_instance_of(StateHandler::CA) 10 | end 11 | end 12 | context 'given a state WITHOUT an existing handler' do 13 | it "returns Nil handler" do 14 | handler = StateHandler.for('PR') 15 | expect(handler).to be_instance_of(StateHandler::UnhandledState) 16 | end 17 | end 18 | end 19 | end 20 | 21 | describe StateHandler::Base do 22 | let(:subject) { StateHandler::Base.new } 23 | 24 | describe 'default #transcribe_balance_response for a handler' do 25 | it 'gives back the verbatim input' do 26 | expect(subject.transcribe_balance_response("hi")).to eq("hi") 27 | end 28 | end 29 | 30 | describe 'default #max_message_length for a handler' do 31 | it 'is 18' do 32 | expect(subject.max_message_length).to eq(18) 33 | end 34 | end 35 | end 36 | 37 | describe StateHandler::AK do 38 | describe 'balance transcription processing' do 39 | let(:transcription_with_trailing_period) { "1:00 moment please. Okay. I pulled up your account information. Your food stamp balance is $3.48. You are eligible to enroll in (new?) free service called." } 40 | 41 | # DUMMY — not taken from real logs 42 | let(:successful_transcription_1) { "blah $123.45 blah" } 43 | 44 | # DUMMY — not taken from real logs 45 | let(:transcription_ebt_not_found) { "blah having trouble locating blah" } 46 | 47 | let(:failed_transcription) { nil } 48 | 49 | context 'for English' do 50 | context 'with transcription containing balance' do 51 | it 'sends response with balance amounts' do 52 | reply_for_user = subject.transcribe_balance_response(successful_transcription_1) 53 | expect(reply_for_user).to eq("Hi! Your food stamp balance is $123.45.") 54 | end 55 | end 56 | 57 | context 'with transcription with trailing period on balance amount' do 58 | it 'sends balance without trailing period' do 59 | reply_for_user = subject.transcribe_balance_response(transcription_with_trailing_period) 60 | expect(reply_for_user).to eq("Hi! Your food stamp balance is $3.48.") 61 | end 62 | end 63 | 64 | context 'with EBT card not found in system' do 65 | it 'sends EBT-not-found message' do 66 | reply_for_user = subject.transcribe_balance_response(transcription_ebt_not_found) 67 | expect(reply_for_user).to eq("I'm sorry, that card number was not found. Please try again.") 68 | end 69 | end 70 | 71 | context 'with a failed (nil) transcription' do 72 | it 'sends EBT-not-found message' do 73 | reply_for_user = subject.transcribe_balance_response(failed_transcription) 74 | expect(reply_for_user).to eq(MessageGenerator.new.having_trouble_try_again_message) 75 | end 76 | end 77 | 78 | context 'with an English-language amount' do 79 | it 'processes it as a dollar amount successfully' do 80 | transcription = "One moment please. OK. I've pulled up your account information. Your food stamp balance is seven hundred sixty six dollars and thirty seven cents. You are eligible to enroll in a free service called my own." 81 | reply_for_user = subject.transcribe_balance_response(transcription) 82 | expect(reply_for_user).to eq("Hi! Your food stamp balance is $766.37.") 83 | end 84 | end 85 | end 86 | 87 | context 'for Spanish' do 88 | let(:language) { :spanish } 89 | 90 | context 'with transcription containing balance variation 1' do 91 | it 'sends response with balance amounts' do 92 | reply_for_user = subject.transcribe_balance_response(successful_transcription_1, language) 93 | expect(reply_for_user).to eq("Hola! El saldo de su cuenta de estampillas para comida es $123.45.") 94 | end 95 | end 96 | 97 | context 'with EBT card not found in system' do 98 | it 'sends EBT-not-found message' do 99 | reply_for_user = subject.transcribe_balance_response(transcription_ebt_not_found, language) 100 | expect(reply_for_user).to eq("Lo siento, no se encontró el número de tarjeta. Por favor, inténtelo de nuevo.") 101 | end 102 | end 103 | 104 | context 'with a failed (nil) transcription' do 105 | it 'sends EBT-not-found message' do 106 | reply_for_user = subject.transcribe_balance_response(failed_transcription, language) 107 | expect(reply_for_user).to eq("Lo siento! Actualmente estamos teniendo problemas comunicándonos con el sistema de EBT. Favor de enviar su # de EBT por texto en unos minutos.") 108 | end 109 | end 110 | end 111 | end 112 | end 113 | 114 | describe StateHandler::CA do 115 | let(:handler) { StateHandler::CA.new } 116 | 117 | it 'serves the correct phone number' do 118 | expect(subject.phone_number).to eq('+18773289677') 119 | end 120 | 121 | it 'gives correct button sequence (ebt # with pauses between digits and pound at end)' do 122 | fake_ebt_number = '11112222' 123 | desired_sequence = subject.button_sequence(fake_ebt_number) 124 | expect(desired_sequence).to eq("wwwwwwwwwwwwwwww1wwwwwwwwwwwwww1ww1ww1ww1ww2ww2ww2ww2ww#wwww") 125 | end 126 | 127 | it 'tells the number of digits a CA EBT card has' do 128 | expect(subject.allowed_number_of_ebt_card_digits).to eq([16]) 129 | end 130 | 131 | describe 'EBT number extraction' do 132 | it 'extracts a valid EBT number for that state from plain text' do 133 | ebt_number = '1111222233334444' 134 | inbound_text = "my ebt is #{ebt_number}" 135 | extracted_number = subject.extract_valid_ebt_number_from_text(inbound_text) 136 | expect(extracted_number).to eq(ebt_number) 137 | end 138 | 139 | it 'processes a valid EBT number with spaces' do 140 | ebt_number = '1111 2222 3333 4444' 141 | extracted_number = subject.extract_valid_ebt_number_from_text(ebt_number) 142 | expect(extracted_number).to eq("1111222233334444") 143 | end 144 | 145 | it 'processes a valid EBT number with dashes' do 146 | ebt_number = '1111-2222-3333-4444' 147 | extracted_number = subject.extract_valid_ebt_number_from_text(ebt_number) 148 | expect(extracted_number).to eq("1111222233334444") 149 | end 150 | 151 | it 'returns :invalid_number if not a valid number' do 152 | inbound_text = 'my ebt is 123' 153 | extracted_number = subject.extract_valid_ebt_number_from_text(inbound_text) 154 | expect(extracted_number).to eq(:invalid_number) 155 | end 156 | 157 | it 'returns a value of 22 for #max_message_length' do 158 | expect(subject.max_message_length).to eq(22) 159 | end 160 | end 161 | 162 | describe 'balance transcriber' do 163 | 164 | 165 | context 'for English' do 166 | let(:language) { :english } 167 | 168 | context 'with transcription containing balance variation 1' do 169 | it 'sends response with balance amounts' do 170 | successful_transcription_1 = "Your food stamp balance is $136.33 your cash account balance is $0 as a reminder by saving the receipt from your last purchase and your last a cash purchase for Cash Bank Transaction you will always have your current balance at and will also print your balance on the Cash Withdrawal receipt to hear the number of Cash Withdrawal for that a transaction fee (running?) this month press 1 to hear your last 10 transactions report a transaction there file a claim or check the status of a claim press 2 to report your card lost stolen or damaged press 3 for (pin?) replacement press 4 for additional options press 5" 171 | 172 | reply_for_user = subject.transcribe_balance_response(successful_transcription_1) 173 | expect(reply_for_user).to eq("Hi! Your food stamp balance is $136.33.") 174 | end 175 | end 176 | 177 | context 'with transcription containing balance variation 2' do 178 | it 'sends response with balance amounts' do 179 | successful_transcription_2 = "(Stamp?) balance is $123.11 your cash account balance is $11.32 as a reminder by saving the receipt from your last purchase and your last a cash purchase or cash back transaction you will always have your current balance at and will also print the balance on the Cash Withdrawal receipt to hear the number of Cash Withdrawal for that a transaction fee this month press 1 to hear your last 10 transactions report a transaction there file a claim or check the status of a claim press 2 to report your card lost stolen or damaged press 3 for (pin?) replacement press 4 for additional options press 5" 180 | 181 | reply_for_user = subject.transcribe_balance_response(successful_transcription_2) 182 | expect(reply_for_user).to eq("Hi! Your food stamp balance is $123.11.") 183 | end 184 | end 185 | 186 | context 'with transcription containing balance variation 3' do 187 | it 'sends response with balance amounts' do 188 | successful_transcription_3 = "Devon Alan is $156.89 your cash account balance is $4.23 as a reminder by saving the receipt from your last purchase and your last the cash purchase or cash back for (action?) you will always have your current balance. I'm at and will also print the balance on the Cash Withdrawal receipt to hear the number of Cash Withdrawal for that a transaction fee (running?) this month press 1 to hear your last 10 transactions report a transaction there file a claim or check the status of a claim press 2 to report your card lost stolen or damaged press 3 for pain placement press 4 for additional options press 5" 189 | 190 | reply_for_user = subject.transcribe_balance_response(successful_transcription_3) 191 | expect(reply_for_user).to eq("Hi! Your food stamp balance is $156.89.") 192 | end 193 | end 194 | 195 | context 'with English language (not number) dollar amounts' do 196 | it 'sends a numerical value back to the user' do 197 | transcription_with_english_amounts = 'Your food stamp balance is six dollars and twenty five cents. Your cash account balance is eleven dollars and sixty nine cents. As a reminder. By saving the receipt from your last purchase and or your last cash purchase or cashback Prinz action. You will always have your.' 198 | 199 | reply_for_user = subject.transcribe_balance_response(transcription_with_english_amounts) 200 | expect(reply_for_user).to eq("Hi! Your food stamp balance is $6.25.") 201 | end 202 | end 203 | 204 | context 'with a transcription with extraneous periods' do 205 | it 'sends response with balance amounts without extra periods' do 206 | successful_transcription_extra_periods = "Your food stamp balance is $9.11. Your cash account balance is $13.93. As a reminder. Bye C." 207 | 208 | reply_for_user = subject.transcribe_balance_response(successful_transcription_extra_periods) 209 | expect(reply_for_user).to eq("Hi! Your food stamp balance is $9.11.") 210 | end 211 | end 212 | 213 | context 'with EBT card not found in system' do 214 | it 'sends EBT-not-found message' do 215 | transcription_ebt_not_found = "Our records indicate the number you have entered it's for an non working card in case your number was entered incorrectly please reenter your 16 digit card number followed by the pound sign." 216 | 217 | reply_for_user = subject.transcribe_balance_response(transcription_ebt_not_found) 218 | expect(reply_for_user).to eq("I'm sorry, that card number was not found. Please try again.") 219 | end 220 | end 221 | 222 | context 'with a failed (nil) transcription' do 223 | let(:failed_transcription) { nil } 224 | 225 | it 'sends EBT-not-found message' do 226 | reply_for_user = subject.transcribe_balance_response(failed_transcription) 227 | expect(reply_for_user).to eq(MessageGenerator.new.having_trouble_try_again_message) 228 | end 229 | end 230 | 231 | context 'with zero dollar values in words' do 232 | it 'correctly parses the zeroes as values' do 233 | transcription_with_zero_as_words = "Balance is zero dollars. Your cash account balance is zero dollars. As a reminder by saving the receipt from your last purchase and or your last cash purchase or cash back transaction." 234 | 235 | reply_for_user = subject.transcribe_balance_response(transcription_with_zero_as_words) 236 | expect(reply_for_user).to eq("Hi! Your food stamp balance is $0.00.") 237 | 238 | end 239 | end 240 | end 241 | 242 | context 'for Spanish' do 243 | let(:language) { :spanish } 244 | 245 | context 'with transcription containing balance variation 1' do 246 | it 'sends response with balance amounts' do 247 | successful_transcription_1 = "Your food stamp balance is $136.33 your cash account balance is $0 as a reminder by saving the receipt from your last purchase and your last a cash purchase for Cash Bank Transaction you will always have your current balance at and will also print your balance on the Cash Withdrawal receipt to hear the number of Cash Withdrawal for that a transaction fee (running?) this month press 1 to hear your last 10 transactions report a transaction there file a claim or check the status of a claim press 2 to report your card lost stolen or damaged press 3 for (pin?) replacement press 4 for additional options press 5" 248 | 249 | reply_for_user = subject.transcribe_balance_response(successful_transcription_1, language) 250 | expect(reply_for_user).to eq("Hola! El saldo de su cuenta de estampillas para comida es $136.33.") 251 | end 252 | end 253 | 254 | context 'with transcription containing balance variation 2' do 255 | it 'sends response with balance amounts' do 256 | successful_transcription_2 = "(Stamp?) balance is $123.11 your cash account balance is $11.32 as a reminder by saving the receipt from your last purchase and your last a cash purchase or cash back transaction you will always have your current balance at and will also print the balance on the Cash Withdrawal receipt to hear the number of Cash Withdrawal for that a transaction fee this month press 1 to hear your last 10 transactions report a transaction there file a claim or check the status of a claim press 2 to report your card lost stolen or damaged press 3 for (pin?) replacement press 4 for additional options press 5" 257 | 258 | reply_for_user = subject.transcribe_balance_response(successful_transcription_2, language) 259 | expect(reply_for_user).to eq("Hola! El saldo de su cuenta de estampillas para comida es $123.11.") 260 | end 261 | end 262 | 263 | context 'with transcription containing balance variation 3' do 264 | it 'sends response with balance amounts' do 265 | successful_transcription_3 = "Devon Alan is $156.89 your cash account balance is $4.23 as a reminder by saving the receipt from your last purchase and your last the cash purchase or cash back for (action?) you will always have your current balance. I'm at and will also print the balance on the Cash Withdrawal receipt to hear the number of Cash Withdrawal for that a transaction fee (running?) this month press 1 to hear your last 10 transactions report a transaction there file a claim or check the status of a claim press 2 to report your card lost stolen or damaged press 3 for pain placement press 4 for additional options press 5" 266 | 267 | reply_for_user = subject.transcribe_balance_response(successful_transcription_3, language) 268 | expect(reply_for_user).to eq("Hola! El saldo de su cuenta de estampillas para comida es $156.89.") 269 | end 270 | end 271 | 272 | context 'with EBT card not found in system' do 273 | transcription_ebt_not_found = "Our records indicate the number you have entered it's for an non working card in case your number was entered incorrectly please reenter your 16 digit card number followed by the pound sign." 274 | 275 | it 'sends EBT-not-found message' do 276 | reply_for_user = subject.transcribe_balance_response(transcription_ebt_not_found, language) 277 | expect(reply_for_user).to eq("Lo siento, no se encontró el número de tarjeta. Por favor, inténtelo de nuevo.") 278 | end 279 | end 280 | 281 | context 'with a failed (nil) transcription' do 282 | let(:failed_transcription) { nil } 283 | 284 | it 'sends EBT-not-found message' do 285 | reply_for_user = subject.transcribe_balance_response(failed_transcription, language) 286 | expect(reply_for_user).to eq("Lo siento! Actualmente estamos teniendo problemas comunicándonos con el sistema de EBT. Favor de enviar su # de EBT por texto en unos minutos.") 287 | end 288 | end 289 | end 290 | end 291 | end 292 | 293 | describe StateHandler::MO do 294 | it 'serves the correct phone number' do 295 | expect(subject.phone_number).to eq('+18009977777') 296 | end 297 | 298 | it 'gives correct button sequence' do 299 | fake_ebt_number = '11112222' 300 | desired_sequence = subject.button_sequence(fake_ebt_number) 301 | expect(desired_sequence).to eq("wwwwwwwwwwwwww1wwwwwwwwwwwwwwwwww2wwwwwwww#{fake_ebt_number}") 302 | end 303 | 304 | it 'tells the number of digits a CA EBT card has' do 305 | expect(subject.allowed_number_of_ebt_card_digits).to eq([16]) 306 | end 307 | 308 | describe 'EBT number extraction' do 309 | it 'extracts a valid EBT number for that state from plain text' do 310 | ebt_number = '1111222233334444' 311 | inbound_text = "my ebt is #{ebt_number}" 312 | extracted_number = subject.extract_valid_ebt_number_from_text(inbound_text) 313 | expect(extracted_number).to eq(ebt_number) 314 | end 315 | 316 | it 'returns :invalid_number if not a valid number' do 317 | inbound_text = 'my ebt is 123' 318 | extracted_number = subject.extract_valid_ebt_number_from_text(inbound_text) 319 | expect(extracted_number).to eq(:invalid_number) 320 | end 321 | end 322 | 323 | describe 'balance transcription processing' do 324 | let(:successful_transcription_1) { "That is the balance you have $154.70 for food stamps to hear that again say repeat that or if you're down here just." } 325 | let(:transcription_ebt_not_found) { "If you don't have a card number say I don't have it otherwise please say or the 16 digit EBT card number now." } 326 | let(:failed_transcription) { nil } 327 | 328 | context 'for English' do 329 | context 'with transcription containing balance variation 1' do 330 | it 'sends response with balance amounts' do 331 | reply_for_user = subject.transcribe_balance_response(successful_transcription_1) 332 | expect(reply_for_user).to eq("Hi! Your food stamp balance is $154.70.") 333 | end 334 | end 335 | 336 | context 'with an English-language amount' do 337 | it 'processes it as a dollar amount successfully' do 338 | # Not taken from logs; just modified above example with English-language dollar amount 339 | transcription = "That is the balance you have one hundred fifty four dollars and seventy cents for food stamps to hear that again say repeat that or if you're down here just." 340 | reply_for_user = subject.transcribe_balance_response(transcription) 341 | expect(reply_for_user).to eq("Hi! Your food stamp balance is $154.70.") 342 | end 343 | end 344 | 345 | context 'with EBT card not found in system' do 346 | it 'sends EBT-not-found message' do 347 | reply_for_user = subject.transcribe_balance_response(transcription_ebt_not_found) 348 | expect(reply_for_user).to eq("I'm sorry, that card number was not found. Please try again.") 349 | end 350 | end 351 | 352 | context 'with a failed (nil) transcription' do 353 | it 'sends EBT-not-found message' do 354 | reply_for_user = subject.transcribe_balance_response(failed_transcription) 355 | expect(reply_for_user).to eq("I'm really sorry! We're having trouble contacting the EBT system right now. Please text your EBT # again in a few minutes.") 356 | end 357 | end 358 | end 359 | 360 | context 'for Spanish' do 361 | let(:language) { :spanish } 362 | 363 | context 'with transcription containing balance variation 1' do 364 | it 'sends response with balance amounts' do 365 | reply_for_user = subject.transcribe_balance_response(successful_transcription_1, language) 366 | expect(reply_for_user).to eq("Hola! El saldo de su cuenta de estampillas para comida es $154.70.") 367 | end 368 | end 369 | 370 | context 'with EBT card not found in system' do 371 | it 'sends EBT-not-found message' do 372 | reply_for_user = subject.transcribe_balance_response(transcription_ebt_not_found, language) 373 | expect(reply_for_user).to eq("Lo siento, no se encontró el número de tarjeta. Por favor, inténtelo de nuevo.") 374 | end 375 | end 376 | 377 | context 'with a failed (nil) transcription' do 378 | it 'sends EBT-not-found message' do 379 | reply_for_user = subject.transcribe_balance_response(failed_transcription, language) 380 | expect(reply_for_user).to eq("Lo siento! Actualmente estamos teniendo problemas comunicándonos con el sistema de EBT. Favor de enviar su # de EBT por texto en unos minutos.") 381 | end 382 | end 383 | end 384 | end 385 | end 386 | 387 | describe StateHandler::NC do 388 | describe 'balance transcription processing' do 389 | 390 | # DUMMY — not taken from real logs 391 | let(:successful_transcription_1) { "blah $123.45 blah" } 392 | 393 | # DUMMY — not taken from real logs 394 | let(:transcription_ebt_not_found) { "blah reenter blah" } 395 | 396 | let(:failed_transcription) { nil } 397 | 398 | context 'with transcription containing balance' do 399 | it 'sends response with balance amounts in language specific to NC' do 400 | reply_for_user = subject.transcribe_balance_response(successful_transcription_1) 401 | expect(reply_for_user).to eq("Hi! Your food and nutrition benefits balance is $123.45.") 402 | end 403 | end 404 | 405 | context 'with an English-language amount' do 406 | it 'processes it as a dollar amount successfully' do 407 | # Not taken from logs; just modified above example with English-language dollar amount 408 | transcription = "blah one hundred twenty three dollars and forty five cents blah" 409 | reply_for_user = subject.transcribe_balance_response(transcription) 410 | expect(reply_for_user).to eq("Hi! Your food and nutrition benefits balance is $123.45.") 411 | end 412 | end 413 | 414 | context 'with EBT card not found in system' do 415 | it 'sends EBT-not-found message' do 416 | reply_for_user = subject.transcribe_balance_response(transcription_ebt_not_found) 417 | expect(reply_for_user).to eq("I'm sorry, that card number was not found. Please try again.") 418 | end 419 | end 420 | 421 | context 'with a failed (nil) transcription' do 422 | it 'sends EBT-not-found message' do 423 | reply_for_user = subject.transcribe_balance_response(failed_transcription) 424 | expect(reply_for_user).to eq(MessageGenerator.new.having_trouble_try_again_message) 425 | end 426 | end 427 | end 428 | end 429 | 430 | describe StateHandler::OK do 431 | describe 'balance transcription processing' do 432 | 433 | # DUMMY — not taken from real logs 434 | let(:successful_transcription_1) { "blah $123.45 blah" } 435 | 436 | # DUMMY — not taken from real logs 437 | let(:transcription_ebt_not_found) { "blah please try again blah" } 438 | 439 | let(:failed_transcription) { nil } 440 | 441 | context 'for English' do 442 | context 'with transcription containing balance' do 443 | it 'sends response with balance amounts' do 444 | reply_for_user = subject.transcribe_balance_response(successful_transcription_1) 445 | expect(reply_for_user).to eq("Hi! Your food stamp balance is $123.45.") 446 | end 447 | end 448 | 449 | context 'with an English-language amount' do 450 | it 'processes it as a dollar amount successfully' do 451 | # Not taken from logs; just modified above example with English-language dollar amount 452 | transcription = "blah one hundred twenty three dollars and forty five cents blah" 453 | reply_for_user = subject.transcribe_balance_response(transcription) 454 | expect(reply_for_user).to eq("Hi! Your food stamp balance is $123.45.") 455 | end 456 | end 457 | 458 | context 'with EBT card not found in system' do 459 | it 'sends EBT-not-found message' do 460 | reply_for_user = subject.transcribe_balance_response(transcription_ebt_not_found) 461 | expect(reply_for_user).to eq("I'm sorry, that card number was not found. Please try again.") 462 | end 463 | end 464 | 465 | context 'with a failed (nil) transcription' do 466 | it 'sends EBT-not-found message' do 467 | reply_for_user = subject.transcribe_balance_response(failed_transcription) 468 | expect(reply_for_user).to eq(MessageGenerator.new.having_trouble_try_again_message) 469 | end 470 | end 471 | end 472 | 473 | context 'for Spanish' do 474 | let(:language) { :spanish } 475 | 476 | context 'with transcription containing balance variation 1' do 477 | it 'sends response with balance amounts' do 478 | reply_for_user = subject.transcribe_balance_response(successful_transcription_1, language) 479 | expect(reply_for_user).to eq("Hola! El saldo de su cuenta de estampillas para comida es $123.45.") 480 | end 481 | end 482 | 483 | context 'with EBT card not found in system' do 484 | it 'sends EBT-not-found message' do 485 | reply_for_user = subject.transcribe_balance_response(transcription_ebt_not_found, language) 486 | expect(reply_for_user).to eq("Lo siento, no se encontró el número de tarjeta. Por favor, inténtelo de nuevo.") 487 | end 488 | end 489 | 490 | context 'with a failed (nil) transcription' do 491 | it 'sends EBT-not-found message' do 492 | reply_for_user = subject.transcribe_balance_response(failed_transcription, language) 493 | expect(reply_for_user).to eq("Lo siento! Actualmente estamos teniendo problemas comunicándonos con el sistema de EBT. Favor de enviar su # de EBT por texto en unos minutos.") 494 | end 495 | end 496 | end 497 | end 498 | end 499 | 500 | describe StateHandler::PA do 501 | describe 'balance transcription' do 502 | let(:successful_transcription_with_extraneous_period) { "Your snap balance is $716. Your cash balance is $294.68. to repeat your account balance press 1 To hear your last 10 transactions on your card. Press." } 503 | 504 | # DUMMY — not taken from real logs 505 | let(:successful_transcription_1) { "blah $136.33 blah $23.87 blah" } 506 | 507 | # DUMMY — not taken from real logs 508 | let(:transcription_ebt_not_found_1) { "blah Invalid Card Number blah" } 509 | let(:transcription_ebt_not_found_2) { "blah invalid card number blah" } 510 | 511 | context 'for English' do 512 | let(:language) { :english } 513 | 514 | context 'with transcription containing balance' do 515 | it 'sends response with balance amounts' do 516 | reply_for_user = subject.transcribe_balance_response(successful_transcription_1) 517 | expect(reply_for_user).to eq("Hi! Your food stamp balance is $136.33 and your cash balance is $23.87.") 518 | end 519 | end 520 | 521 | context 'with an English-language amount' do 522 | it 'processes it as a dollar amount successfully' do 523 | # Not taken directly from logs; modified the above with a transcription of English language numbers from logs 524 | transcription = "Your snap balance is ten dollars and twenty two cents. Your cash balance is one dollar. To repeat your account balance press 1 To hear your last 10 transactions on your card. Press." 525 | reply_for_user = subject.transcribe_balance_response(transcription) 526 | expect(reply_for_user).to eq("Hi! Your food stamp balance is $10.22 and your cash balance is $1.00.") 527 | end 528 | end 529 | 530 | context 'with transcription with balance and extraneous periods' do 531 | it 'transcribes without extraneous periods in amounts' do 532 | reply_for_user = subject.transcribe_balance_response(successful_transcription_with_extraneous_period) 533 | expect(reply_for_user).to eq("Hi! Your food stamp balance is $716 and your cash balance is $294.68.") 534 | end 535 | end 536 | 537 | context 'with EBT card not found in system (variation 1 - capitalized)' do 538 | it 'sends EBT-not-found message' do 539 | reply_for_user = subject.transcribe_balance_response(transcription_ebt_not_found_1) 540 | expect(reply_for_user).to eq("I'm sorry, that card number was not found. Please try again.") 541 | end 542 | end 543 | 544 | context 'with EBT card not found in system (variation 2 - not capitalized)' do 545 | it 'sends EBT-not-found message' do 546 | reply_for_user = subject.transcribe_balance_response(transcription_ebt_not_found_2) 547 | expect(reply_for_user).to eq("I'm sorry, that card number was not found. Please try again.") 548 | end 549 | end 550 | 551 | context 'with a failed (nil) transcription' do 552 | let(:failed_transcription) { nil } 553 | 554 | it 'sends EBT-not-found message' do 555 | reply_for_user = subject.transcribe_balance_response(failed_transcription) 556 | expect(reply_for_user).to eq(MessageGenerator.new.having_trouble_try_again_message) 557 | end 558 | end 559 | end 560 | 561 | context 'for Spanish' do 562 | let(:language) { :spanish } 563 | 564 | context 'with transcription containing balance' do 565 | it 'sends response with balance amounts' do 566 | reply_for_user = subject.transcribe_balance_response(successful_transcription_1, language) 567 | expect(reply_for_user).to eq("Hola! El saldo de su cuenta de estampillas para comida es $136.33 y su balance de dinero en efectivo es $23.87.") 568 | end 569 | end 570 | 571 | context 'with EBT card not found in system' do 572 | it 'sends EBT-not-found message' do 573 | reply_for_user = subject.transcribe_balance_response(transcription_ebt_not_found_1, language) 574 | expect(reply_for_user).to eq("Lo siento, no se encontró el número de tarjeta. Por favor, inténtelo de nuevo.") 575 | end 576 | end 577 | 578 | context 'with a failed (nil) transcription' do 579 | let(:failed_transcription) { nil } 580 | 581 | it 'sends EBT-not-found message' do 582 | reply_for_user = subject.transcribe_balance_response(failed_transcription, language) 583 | expect(reply_for_user).to eq("Lo siento! Actualmente estamos teniendo problemas comunicándonos con el sistema de EBT. Favor de enviar su # de EBT por texto en unos minutos.") 584 | end 585 | end 586 | end 587 | end 588 | end 589 | 590 | describe StateHandler::TX do 591 | it 'serves the correct phone number' do 592 | expect(subject.phone_number).to eq('+18007777328') 593 | end 594 | 595 | it 'gives correct button sequence' do 596 | fake_ebt_number = '11112222' 597 | desired_sequence = subject.button_sequence(fake_ebt_number) 598 | expect(desired_sequence).to eq("wwww1wwwwww#{fake_ebt_number}wwww") 599 | end 600 | 601 | it 'tells the number of digits a CA EBT card has' do 602 | expect(subject.allowed_number_of_ebt_card_digits).to eq([19]) 603 | end 604 | 605 | describe 'EBT number extraction' do 606 | it 'extracts a valid EBT number for that state from plain text' do 607 | ebt_number = '1111222233334444555' 608 | inbound_text = "my ebt is #{ebt_number}" 609 | extracted_number = subject.extract_valid_ebt_number_from_text(inbound_text) 610 | expect(extracted_number).to eq(ebt_number) 611 | end 612 | 613 | it 'returns :invalid_number if not a valid number' do 614 | inbound_text = 'my ebt is 123' 615 | extracted_number = subject.extract_valid_ebt_number_from_text(inbound_text) 616 | expect(extracted_number).to eq(:invalid_number) 617 | end 618 | end 619 | 620 | describe 'balance transcription processing' do 621 | let(:successful_transcription_1) { "(Who?) the account balance for the card number entered is $154.70 to end this call press 1 to repeat your account balance press 2 to report a lost or still in card press 3 if you would like to select a new pen for your account." } 622 | let(:successful_transcription_too_many_digits_in_balance) { "For the card number entered is $600802.17 to end this call press 1 to repeat your account balance press 2 to report a lost or (still?) in card press 3 if you would like to select a new pen for you Rick." } 623 | let(:transcription_ebt_not_found) { "Hey, Dan Invalid Card Number please enter the 16 numbers on the first line of the card and the last 3 numbers in the lower left hand corner on the second line of the card if your card has been lost or stolen and you do not have your card number please hold to disable your card please enter (then?)." } 624 | let(:failed_transcription) { nil } 625 | 626 | context 'for English' do 627 | context 'with an English-language amount' do 628 | it 'processes it as a dollar amount successfully' do 629 | # Not taken from logs; modified the above with English language numbers 630 | transcription = "(Who?) the account balance for the card number entered is one hundred fifty four dollars and seventy cents. To end this call press one to repeat your account balance press two to report a lost or still in card press three if you would like to select a new pen for your account." 631 | reply_for_user = subject.transcribe_balance_response(transcription) 632 | expect(reply_for_user).to eq("Hi! Your food stamp balance is $154.70.") 633 | end 634 | end 635 | 636 | context 'with transcription containing balance variation 1' do 637 | it 'sends response with balance amounts' do 638 | reply_for_user = subject.transcribe_balance_response(successful_transcription_1) 639 | expect(reply_for_user).to eq("Hi! Your food stamp balance is $154.70.") 640 | end 641 | end 642 | 643 | context 'with transcription with huge dollar amount' do 644 | it 'sends response with balance amounts' do 645 | reply_for_user = subject.transcribe_balance_response(successful_transcription_too_many_digits_in_balance) 646 | expect(reply_for_user).to eq("Hi! Your food stamp balance is $682.17.") 647 | end 648 | end 649 | 650 | context 'with EBT card not found in system' do 651 | it 'sends EBT-not-found message' do 652 | reply_for_user = subject.transcribe_balance_response(transcription_ebt_not_found) 653 | expect(reply_for_user).to eq("I'm sorry, that card number was not found. Please try again.") 654 | end 655 | end 656 | 657 | context 'with a failed (nil) transcription' do 658 | it 'sends EBT-not-found message' do 659 | reply_for_user = subject.transcribe_balance_response(failed_transcription) 660 | expect(reply_for_user).to eq(MessageGenerator.new.having_trouble_try_again_message) 661 | end 662 | end 663 | end 664 | 665 | context 'for Spanish' do 666 | let(:language) { :spanish } 667 | 668 | context 'with transcription containing balance variation 1' do 669 | it 'sends response with balance amounts' do 670 | reply_for_user = subject.transcribe_balance_response(successful_transcription_1, language) 671 | expect(reply_for_user).to eq("Hola! El saldo de su cuenta de estampillas para comida es $154.70.") 672 | end 673 | end 674 | 675 | context 'with EBT card not found in system' do 676 | it 'sends EBT-not-found message' do 677 | reply_for_user = subject.transcribe_balance_response(transcription_ebt_not_found, language) 678 | expect(reply_for_user).to eq("Lo siento, no se encontró el número de tarjeta. Por favor, inténtelo de nuevo.") 679 | end 680 | end 681 | 682 | context 'with a failed (nil) transcription' do 683 | it 'sends EBT-not-found message' do 684 | reply_for_user = subject.transcribe_balance_response(failed_transcription, language) 685 | expect(reply_for_user).to eq("Lo siento! Actualmente estamos teniendo problemas comunicándonos con el sistema de EBT. Favor de enviar su # de EBT por texto en unos minutos.") 686 | end 687 | end 688 | end 689 | end 690 | end 691 | 692 | describe StateHandler::VA do 693 | describe 'balance transcription' do 694 | let(:transcription_with_trailing_period) { "Your snap balance is $221.90. As a reminder by saving to (receive?) from your last purchase you'll know your current balance. You can also access your balance online at www.EBT dot a C at." } 695 | 696 | # DUMMY — not taken from real logs 697 | let(:successful_transcription_1) { "blah $136.33 blah $23.87 blah" } 698 | 699 | # DUMMY — not taken from real logs 700 | let(:transcription_ebt_not_found_1) { "blah Invalid Card Number blah" } 701 | let(:transcription_ebt_not_found_2) { "blah invalid card number blah" } 702 | 703 | context 'for English' do 704 | let(:language) { :english } 705 | 706 | context 'with an English-language amount' do 707 | it 'processes it as a dollar amount successfully' do 708 | # Not taken from logs; modified the above with English language numbers 709 | transcription = "Your snap balance is two hundred twenty one dollars and ninety cents. As a reminder by saving to (receive?) from your last purchase you'll know your current balance. You can also access your balance online at www.EBT dot a C at." 710 | reply_for_user = subject.transcribe_balance_response(transcription) 711 | expect(reply_for_user).to eq("Hi! Your food stamp balance is $221.90.") 712 | end 713 | end 714 | 715 | context 'with transcription containing balance' do 716 | it 'sends response with balance amounts' do 717 | reply_for_user = subject.transcribe_balance_response(successful_transcription_1) 718 | expect(reply_for_user).to eq("Hi! Your food stamp balance is $136.33.") 719 | end 720 | end 721 | 722 | context 'with transcription with trailing period in amount' do 723 | it 'sends response with balance without trailing period' do 724 | reply_for_user = subject.transcribe_balance_response(transcription_with_trailing_period) 725 | expect(reply_for_user).to eq("Hi! Your food stamp balance is $221.90.") 726 | end 727 | end 728 | 729 | context 'with EBT card not found in system (variation 1 - capitalized)' do 730 | it 'sends EBT-not-found message' do 731 | reply_for_user = subject.transcribe_balance_response(transcription_ebt_not_found_1) 732 | expect(reply_for_user).to eq("I'm sorry, that card number was not found. Please try again.") 733 | end 734 | end 735 | 736 | context 'with EBT card not found in system (variation 2 - not capitalized)' do 737 | it 'sends EBT-not-found message' do 738 | reply_for_user = subject.transcribe_balance_response(transcription_ebt_not_found_2) 739 | expect(reply_for_user).to eq("I'm sorry, that card number was not found. Please try again.") 740 | end 741 | end 742 | 743 | context 'with a failed (nil) transcription' do 744 | let(:failed_transcription) { nil } 745 | 746 | it 'sends EBT-not-found message' do 747 | reply_for_user = subject.transcribe_balance_response(failed_transcription) 748 | expect(reply_for_user).to eq(MessageGenerator.new.having_trouble_try_again_message) 749 | end 750 | end 751 | end 752 | 753 | context 'for Spanish' do 754 | let(:language) { :spanish } 755 | 756 | context 'with transcription containing balance' do 757 | it 'sends response with balance amounts' do 758 | reply_for_user = subject.transcribe_balance_response(successful_transcription_1, language) 759 | expect(reply_for_user).to eq("Hola! El saldo de su cuenta de estampillas para comida es $136.33.") 760 | end 761 | end 762 | 763 | context 'with EBT card not found in system' do 764 | it 'sends EBT-not-found message' do 765 | reply_for_user = subject.transcribe_balance_response(transcription_ebt_not_found_1, language) 766 | expect(reply_for_user).to eq("Lo siento, no se encontró el número de tarjeta. Por favor, inténtelo de nuevo.") 767 | end 768 | end 769 | 770 | context 'with a failed (nil) transcription' do 771 | let(:failed_transcription) { nil } 772 | 773 | it 'sends EBT-not-found message' do 774 | reply_for_user = subject.transcribe_balance_response(failed_transcription, language) 775 | expect(reply_for_user).to eq("Lo siento! Actualmente estamos teniendo problemas comunicándonos con el sistema de EBT. Favor de enviar su # de EBT por texto en unos minutos.") 776 | end 777 | end 778 | end 779 | end 780 | end 781 | 782 | describe StateHandler::UnhandledState do 783 | let(:subject) { StateHandler::UnhandledState.new } 784 | 785 | it 'uses CA handler methods' do 786 | expect(subject.phone_number).to eq(StateHandler::CA.new.phone_number) 787 | expect(subject.button_sequence('123')).to eq(StateHandler::CA.new.button_sequence('123')) 788 | end 789 | end 790 | --------------------------------------------------------------------------------