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.
- data/.gitignore +2 -1
- data/.rspec +3 -0
- data/README.md +23 -20
- data/Rakefile +5 -0
- data/bin/last-resort +39 -2
- data/last-resort.gemspec +9 -5
- data/lib/last-resort.rb +10 -17
- data/lib/last-resort/application.rb +120 -0
- data/lib/last-resort/commands.rb +159 -0
- data/lib/last-resort/config.rb +131 -0
- data/lib/last-resort/contextio.rb +1 -0
- data/lib/last-resort/scheduler.rb +29 -18
- data/lib/last-resort/twilio.rb +49 -47
- data/lib/last-resort/version.rb +2 -4
- data/lib/last-resort/webhooks.rb +6 -5
- data/spec/controller_spec.rb +106 -0
- data/spec/scheduler_spec.rb +77 -0
- data/spec/spec_helper.rb +11 -2
- data/support/Gemfile +3 -0
- data/support/config.ru +11 -0
- data/support/dot_env +7 -0
- data/support/dot_gitignore +1 -0
- metadata +88 -30
- data/config/config.rb +0 -35
- data/lib/last-resort/config-lang.rb +0 -74
- data/lib/last-resort/controller.rb +0 -78
@@ -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
|
@@ -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
|
-
|
5
|
-
|
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
|
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
|
23
|
-
|
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?
|
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?
|
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.
|
50
|
-
day == day_of_week
|
51
|
-
end
|
55
|
+
expanded_days.include? day_of_week
|
52
56
|
end
|
53
57
|
|
54
|
-
def expand_if_possible
|
55
|
-
|
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
|
-
|
70
|
+
WEEKDAYS
|
62
71
|
when :weekends
|
63
|
-
|
72
|
+
WEEKENDS
|
73
|
+
when :monday, :tuesday, :wednesday, :thursday, :friday
|
74
|
+
[symbol_or_time_unit]
|
64
75
|
else
|
65
|
-
|
76
|
+
raise "#{symbol_or_time_unit} is not a recognized expandable symbol"
|
66
77
|
end
|
67
78
|
end
|
68
79
|
end
|
data/lib/last-resort/twilio.rb
CHANGED
@@ -8,63 +8,65 @@ HOST = ""
|
|
8
8
|
|
9
9
|
module LastResort
|
10
10
|
|
11
|
-
|
12
|
-
|
11
|
+
class Contact
|
12
|
+
attr_accessor :name, :number
|
13
13
|
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
14
|
+
def initialize(name = "", number = "")
|
15
|
+
@name = name
|
16
|
+
@number = number
|
17
|
+
end
|
18
|
+
end
|
19
19
|
|
20
|
-
|
20
|
+
# Represents the email notification (exception) from ContextIO.
|
21
|
+
# Created when our webhook is called from ContextIO.
|
22
|
+
class ExceptionSession
|
21
23
|
|
22
|
-
|
23
|
-
|
24
|
+
# Array of strings representing numbers
|
25
|
+
attr_accessor :contacts, :client, :call, :index, :description, :handled
|
24
26
|
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
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
|
-
|
34
|
-
|
35
|
-
|
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
|
-
|
39
|
+
return false if @handled || @index >= @contacts.size
|
38
40
|
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
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
|
-
|
48
|
-
|
49
|
+
return true
|
50
|
+
end
|
49
51
|
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
52
|
+
# Called when someone in the queue has handled this
|
53
|
+
def end
|
54
|
+
@handled = true
|
55
|
+
end
|
54
56
|
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
57
|
+
# Begin the notification cycle
|
58
|
+
def notify
|
59
|
+
self.call_next
|
60
|
+
end
|
59
61
|
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
62
|
+
# Name of the latest callee (latest call)
|
63
|
+
def callee_name
|
64
|
+
@contacts[@index].name
|
65
|
+
end
|
64
66
|
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
67
|
+
# Number of latest callee (latest call)
|
68
|
+
def callee_number
|
69
|
+
@contacts[@index].number
|
70
|
+
end
|
71
|
+
end
|
70
72
|
end
|
data/lib/last-resort/version.rb
CHANGED
data/lib/last-resort/webhooks.rb
CHANGED
@@ -1,13 +1,14 @@
|
|
1
1
|
module LastResort
|
2
2
|
class WebHookCreator
|
3
3
|
def self.create_hooks
|
4
|
-
|
4
|
+
config = Config.new
|
5
|
+
contextio = ContextIO::Connection.new(config.contextio_key, config.contextio_secret)
|
5
6
|
|
6
|
-
# Delete everything
|
7
|
-
contextio.deleteAllWebhooks
|
7
|
+
# Delete everything...
|
8
|
+
contextio.deleteAllWebhooks config.contextio_account
|
8
9
|
|
9
|
-
# then recreate based on the configuration
|
10
|
-
|
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
|