last-resort 0.0.8 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,131 @@
1
+ module LastResort
2
+ # The configuration class is used to build up contact and scheduling information for
3
+ # Last Resort.
4
+ #
5
+ # Instances of this class power the schedule.rb's DSL. By default it will attempt to
6
+ # load in and evaluate a schedule.rb file upon construction, but it can also be manually
7
+ # configured.
8
+ class Config
9
+
10
+ DOT_ENV_PATH = ".env"
11
+ CONFIG_PATH = "schedule.rb"
12
+
13
+ attr_accessor :host, # The host provided to services for webhooks
14
+ :contacts, # A map of symbolized names to contact hashes
15
+ :matchers, # A list of objects describing emails that trigger alerts
16
+ :schedules, # A list of day and hour ranges and their associated contacts
17
+ :local_utc_offset_in_seconds, # The developer's local time offset, used server side to determine when to call
18
+ :twilio_sid, :twilio_auth_token, # Twilio creds
19
+ :contextio_account, :contextio_key, :contextio_secret # Context.io creds
20
+
21
+ # If skip_schedule_load is true, the config instance will not attempt to load
22
+ # schedule.rb. Useful for unit testing or programmatic configuration.
23
+ def initialize skip_schedule_load = false
24
+ @contacts = {}
25
+ @matchers = []
26
+ @schedules = []
27
+ run_config_in_context unless skip_schedule_load
28
+ end
29
+
30
+ protected
31
+
32
+ def run_config_in_context
33
+ raise "No config file found at #{CONFIG_PATH}" unless File.exist? CONFIG_PATH
34
+
35
+ source = open(CONFIG_PATH).read
36
+ self.instance_eval source, File.absolute_path(CONFIG_PATH)
37
+ end
38
+
39
+ def configure(params)
40
+ params = extract_env_config if params == :using_env
41
+
42
+ assert_complete_config params
43
+
44
+ @host = params[:host]
45
+ @twilio_sid = params[:twilio_sid]
46
+ @twilio_auth_token = params[:twilio_auth_token]
47
+ @contextio_key = params[:contextio_key]
48
+ @contextio_secret = params[:contextio_secret]
49
+ @contextio_account = params[:contextio_account]
50
+ end
51
+
52
+ def local_utc_offset(offset_in_hours)
53
+ @local_utc_offset_in_seconds = offset_in_hours * 60 * 60
54
+ end
55
+
56
+ def contact(name, phone)
57
+ @contacts[name] = { :name => name, :phone => scrub_phone(phone) }
58
+ end
59
+
60
+ def match(matcher)
61
+ @matchers << matcher
62
+ end
63
+
64
+ def between(hours, options)
65
+ hours = hours.is_a?(Array) ? hours : [hours]
66
+
67
+ days = options[:on] || :everyday
68
+ days = days.is_a?(Array) ? days : [days]
69
+
70
+ @current_schedule = {
71
+ :hours => hours,
72
+ :days => days
73
+ }
74
+
75
+ yield
76
+
77
+ @schedules << @current_schedule
78
+ @current_schedule = nil
79
+ end
80
+
81
+ def call(contacts)
82
+ contacts = contacts.is_a?(Array) ? contacts : [contacts]
83
+ @current_schedule[:contacts] = contacts
84
+ end
85
+
86
+ class << self
87
+ def populate_env_if_required
88
+ # Check if ENV is already populated
89
+ return if ENV.has_key? 'LAST_RESORT_HOST'
90
+
91
+ # Raises an exception if a .env can't be found
92
+ raise "No .env file found in working directory" unless File.exists? DOT_ENV_PATH
93
+
94
+ # Set the environment variables
95
+ open(DOT_ENV_PATH).lines.each do |line|
96
+ parts = line.split('=')
97
+ ENV[parts[0]] = parts[1]
98
+ end
99
+ end
100
+ end
101
+
102
+ private
103
+
104
+ def scrub_phone(phone)
105
+ if phone.start_with? "+"
106
+ phone = "+#{phone[1..-1].gsub(/\D/, '')}"
107
+ else
108
+ phone.gsub!(/\D/, '')
109
+ end
110
+
111
+ phone
112
+ end
113
+
114
+ def extract_env_config
115
+ Config.populate_env_if_required
116
+
117
+ { :host => ENV['LAST_RESORT_HOST'],
118
+ :twilio_sid => ENV['TWILIO_SID'],
119
+ :twilio_auth_token => ENV['TWILIO_AUTH_TOKEN'],
120
+ :contextio_account => ENV['CONTEXTIO_ACCOUNT'],
121
+ :contextio_key => ENV['CONTEXTIO_KEY'],
122
+ :contextio_secret => ENV['CONTEXTIO_SECRET'] }
123
+ end
124
+
125
+ def assert_complete_config(params)
126
+ [:host, :twilio_sid, :twilio_auth_token, :contextio_account, :contextio_key, :contextio_secret ].each do |config_key|
127
+ raise "#{config_key} not found in configuration" if params[:config_key].nil?
128
+ end
129
+ end
130
+ end
131
+ end
@@ -3,6 +3,7 @@ require 'json'
3
3
  require 'net/http'
4
4
 
5
5
  module ContextIO
6
+ # Defines the API interface connection to ContextIO.
6
7
  class Connection
7
8
  VERSION = "2.0"
8
9
 
@@ -1,14 +1,17 @@
1
1
  module LastResort
2
+ # The schedule as defined in the 'schedule.rb' file.
2
3
  class Scheduler
3
4
 
4
- def initialize
5
- @config = CONFIG
5
+ ALL_DAYS = [:monday, :tuesday, :wednesday, :thursday, :friday, :saturday, :sunday]
6
+ WEEKDAYS = [:monday, :tuesday, :wednesday, :thursday, :friday]
7
+ WEEKENDS = [:saturday, :sunday]
8
+
9
+ def initialize config = Config.new
10
+ @config = config
6
11
  end
7
12
 
8
13
  def get_matching_schedule
9
- matched_schedule = @config.schedules.find do |schedule|
10
- match?(schedule)
11
- end
14
+ matched_schedule = @config.schedules.find { |schedule| match?(schedule) }
12
15
 
13
16
  if matched_schedule.nil?
14
17
  puts "No matched schedule"
@@ -19,11 +22,15 @@ module LastResort
19
22
  end
20
23
  end
21
24
 
22
- def match? schedule
23
- match_hours?(schedule[:hours]) and match_days?(schedule[:days])
25
+ def zone_adjusted_time
26
+ Time.now.utc + @config.local_utc_offset_in_seconds
27
+ end
28
+
29
+ def match?(schedule, time_to_match = zone_adjusted_time)
30
+ match_hours?(schedule[:hours], time_to_match) && match_days?(schedule[:days], time_to_match)
24
31
  end
25
32
 
26
- def match_hours? hours, time_to_match = Time.now
33
+ def match_hours?(hours, time_to_match)
27
34
  expanded_hours = []
28
35
  hours.each do |hour|
29
36
  expanded_hours += expand_if_possible(hour)
@@ -38,31 +45,35 @@ module LastResort
38
45
  end
39
46
  end
40
47
 
41
- def match_days? days, time_to_match = Time.now
48
+ def match_days?(days, time_to_match)
42
49
  day_of_week = time_to_match.strftime("%A").downcase.to_sym
43
-
44
50
  expanded_days = []
45
51
  days.each do |day|
46
52
  expanded_days += expand_if_possible(day)
47
53
  end
48
54
 
49
- expanded_days.any? do |day|
50
- day == day_of_week
51
- end
55
+ expanded_days.include? day_of_week
52
56
  end
53
57
 
54
- def expand_if_possible symbol
55
- case symbol
58
+ def expand_if_possible(symbol_or_time_unit)
59
+ return [symbol_or_time_unit] if
60
+ symbol_or_time_unit.is_a? Fixnum or symbol_or_time_unit.is_a? Range
61
+
62
+ case symbol_or_time_unit
56
63
  when :all_hours
57
64
  [0..23]
58
65
  when :off_hours
59
66
  [0..8, 17..23]
67
+ when :everyday
68
+ ALL_DAYS
60
69
  when :weekdays
61
- [:monday, :tuesday, :wednesday, :thursday, :friday]
70
+ WEEKDAYS
62
71
  when :weekends
63
- [:saturday, :sunday]
72
+ WEEKENDS
73
+ when :monday, :tuesday, :wednesday, :thursday, :friday
74
+ [symbol_or_time_unit]
64
75
  else
65
- [symbol]
76
+ raise "#{symbol_or_time_unit} is not a recognized expandable symbol"
66
77
  end
67
78
  end
68
79
  end
@@ -8,63 +8,65 @@ HOST = ""
8
8
 
9
9
  module LastResort
10
10
 
11
- class Contact
12
- attr_accessor :name, :number
11
+ class Contact
12
+ attr_accessor :name, :number
13
13
 
14
- def initialize(name = "", number = "")
15
- @name = name
16
- @number = number
17
- end
18
- end
14
+ def initialize(name = "", number = "")
15
+ @name = name
16
+ @number = number
17
+ end
18
+ end
19
19
 
20
- class ExceptionSession
20
+ # Represents the email notification (exception) from ContextIO.
21
+ # Created when our webhook is called from ContextIO.
22
+ class ExceptionSession
21
23
 
22
- # Array of strings representing numbers
23
- attr_accessor :contacts, :client, :call, :index, :description, :handled
24
+ # Array of strings representing numbers
25
+ attr_accessor :contacts, :client, :call, :index, :description, :handled
24
26
 
25
- def initialize(contacts = [], description = "a general exception has occurred")
26
- @contacts = contacts
27
- @description = description
28
- @client = Twilio::REST::Client.new ACCOUNT_SID, AUTH_TOKEN
29
- @index = -1
30
- @handled = false
31
- end
27
+ def initialize(contacts = [], description = "A general exception has occurred")
28
+ @contacts = contacts
29
+ @description = description
30
+ @client = Twilio::REST::Client.new ACCOUNT_SID, AUTH_TOKEN
31
+ @index = -1
32
+ @handled = false
33
+ end
32
34
 
33
- # Return false if call was not made
34
- def call_next
35
- @index += 1
35
+ # Call the next Contact in the queue. Returns false if call was not made
36
+ def call_next
37
+ @index += 1
36
38
 
37
- return false if @contacts.empty? || @index >= @contacts.size || @handled
39
+ return false if @handled || @index >= @contacts.size
38
40
 
39
- # Make the call
40
- @call = @client.account.calls.create(
41
- :from => FROM_NUMBER,
42
- :to => @contacts[@index].number,
43
- :url => "http://#{HOST}/twilio/call",
44
- :status_callback => "http://#{HOST}/twilio/status_callback"
45
- )
41
+ # Make the call
42
+ @call = @client.account.calls.create(
43
+ :from => FROM_NUMBER,
44
+ :to => @contacts[@index].number,
45
+ :url => "http://#{HOST}/twilio/call",
46
+ :status_callback => "http://#{HOST}/twilio/status_callback"
47
+ )
46
48
 
47
- return true
48
- end
49
+ return true
50
+ end
49
51
 
50
- # Called when someone in the queue has handled this
51
- def end
52
- @handled = true
53
- end
52
+ # Called when someone in the queue has handled this
53
+ def end
54
+ @handled = true
55
+ end
54
56
 
55
- # Begin the notification cycle
56
- def notify
57
- self.call_next
58
- end
57
+ # Begin the notification cycle
58
+ def notify
59
+ self.call_next
60
+ end
59
61
 
60
- # Name of the latest callee (latest call)
61
- def callee_name
62
- @contacts[@index].name
63
- end
62
+ # Name of the latest callee (latest call)
63
+ def callee_name
64
+ @contacts[@index].name
65
+ end
64
66
 
65
- # Number of latest callee (latest call)
66
- def callee_number
67
- @contacts[@index].number
68
- end
69
- end
67
+ # Number of latest callee (latest call)
68
+ def callee_number
69
+ @contacts[@index].number
70
+ end
71
+ end
70
72
  end
@@ -1,5 +1,3 @@
1
- module Last
2
- module Resort
3
- VERSION = "0.0.8"
4
- end
1
+ module LastResort
2
+ VERSION = "0.0.10"
5
3
  end
@@ -1,13 +1,14 @@
1
1
  module LastResort
2
2
  class WebHookCreator
3
3
  def self.create_hooks
4
- contextio = ContextIO::Connection.new(CONFIG.contextio_key, CONFIG.contextio_secret)
4
+ config = Config.new
5
+ contextio = ContextIO::Connection.new(config.contextio_key, config.contextio_secret)
5
6
 
6
- # Delete everything
7
- contextio.deleteAllWebhooks CONFIG.contextio_account
7
+ # Delete everything...
8
+ contextio.deleteAllWebhooks config.contextio_account
8
9
 
9
- # then recreate based on the configuration
10
- CONFIG.matchers.each do |matcher|
10
+ # ...then recreate based on the configuration
11
+ config.matchers.each do |matcher|
11
12
  contextio.createWebhook CONFIG.contextio_account,
12
13
  :callback_url => "http://#{CONFIG.host}/matched_email",
13
14
  :failure_notif_url => "http://google.ca",
@@ -0,0 +1,106 @@
1
+ require 'spec_helper'
2
+ require 'last-resort/twilio'
3
+
4
+
5
+ describe 'LastResort' do
6
+ include Rack::Test::Methods
7
+
8
+ def app
9
+ LastResort::Application
10
+ end
11
+
12
+ it "should be up and running" do
13
+ get '/twilio'
14
+ last_response.should be_ok
15
+ last_response.body.should == "Twilio callbacks up and running!"
16
+ end
17
+
18
+ describe "on a phone call" do
19
+ before(:each) do
20
+ @exception_session = double("LastResort::ExceptionSession")
21
+ @exception_session.stub("callee_name").and_return("Ian Ha")
22
+ @exception_session.stub("description").and_return("+111")
23
+ LastResort::Application.exception_session = @exception_session
24
+ end
25
+
26
+ it "should hang up when the user does not answer the call" do
27
+ post "/twilio/call", {:CallStatus => 'no-answer'}
28
+ last_response.body.should eq Twilio::TwiML::Response.new { |r| r.Hangup }.text
29
+ end
30
+
31
+ it "should gather a single digit response if the user picks up" do
32
+ post "/twilio/call", {:CallStatus => 'completed'}
33
+ doc = Nokogiri::XML(last_response.body)
34
+ doc.xpath('//Gather[@numDigits="1"]').size.should eq 1
35
+ end
36
+
37
+ it "should call the next person when a call ends" do
38
+ @exception_session.should_receive("call_next")
39
+ post '/twilio/status_callback', {}
40
+ end
41
+
42
+ context "when receiving user input" do
43
+
44
+ it "should hangup if selected digit is not 1" do
45
+ post '/twilio/gather_digits', {:Digits => "0"}
46
+ last_response.body.should eq Twilio::TwiML::Response.new { |r| r.Hangup }.text
47
+ end
48
+
49
+ it "should end the call queue when receiving a digit of 1" do
50
+ @exception_session.should_receive("end")
51
+ post '/twilio/gather_digits', {:Digits => "1"}
52
+ end
53
+
54
+ it "should say a final message and hangup when receiving a digit of 1" do
55
+ @exception_session.should_receive("end")
56
+ post '/twilio/gather_digits', {:Digits => "1"}
57
+ doc = Nokogiri::XML(last_response.body)
58
+ doc.xpath('//Hangup').size.should eq 1
59
+ doc.xpath('//Say').size.should eq 1
60
+ end
61
+ end
62
+ end
63
+
64
+ describe "on receiving a matched email" do
65
+ before (:each) do
66
+ $exception_session = double("LastResort::ExceptionSession")
67
+ $exception_session.stub("callee_name").and_return("Ian Ha")
68
+ $exception_session.stub("description").and_return("+111")
69
+ $exception_session.should_receive(:notify)
70
+
71
+ $scheduler = double("LastResort::Scheduler")
72
+ $scheduler.should_receive(:get_matching_schedule).with(any_args()).and_return({:contacts => []})
73
+
74
+ JSON.should_receive(:parse).with(any_args()).and_return({"message_data" => {"subject" => 'foo'}})
75
+
76
+ # Here we break open the class so we can stub out certain methods. This, unfortunately, is
77
+ # necessary (until we figure out something better) because the "app" method above can only
78
+ # return a class, not a hacked up instance.
79
+ #
80
+ # Also, global variables. Sorry. :(
81
+ class LastResort::Application
82
+ def new_scheduler
83
+ $scheduler
84
+ end
85
+
86
+ def new_exception_session(*args)
87
+ $exception_session
88
+ end
89
+
90
+ def get_request_body(*args)
91
+ ""
92
+ end
93
+ end
94
+
95
+ # puts LastResort::ExceptionSession.methods.sort
96
+ # LastResort::ExceptionSession.should_receive(:new).with(any_args()).and_return(@exception_session)
97
+ # LastResort::ExceptionSession.should_receive("exception_session").and_return(@exception_session)
98
+ end
99
+
100
+ it "should begin call sequence" do
101
+ post '/matched_email', {}
102
+ end
103
+ end
104
+
105
+
106
+ end