stealth 1.0.4 → 1.1.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +19 -4
  3. data/Gemfile +1 -1
  4. data/Gemfile.lock +32 -77
  5. data/README.md +1 -0
  6. data/VERSION +1 -1
  7. data/docs/03-basics.md +18 -2
  8. data/docs/04-sessions.md +12 -0
  9. data/docs/05-controllers.md +3 -3
  10. data/docs/07-replies.md +42 -1
  11. data/lib/stealth/base.rb +23 -9
  12. data/lib/stealth/controller/catch_all.rb +1 -1
  13. data/lib/stealth/controller/controller.rb +21 -9
  14. data/lib/stealth/controller/dynamic_delay.rb +64 -0
  15. data/lib/stealth/controller/replies.rb +55 -14
  16. data/lib/stealth/flow/base.rb +1 -1
  17. data/lib/stealth/flow/specification.rb +30 -8
  18. data/lib/stealth/flow/state.rb +10 -5
  19. data/lib/stealth/generators/builder/Gemfile +3 -0
  20. data/lib/stealth/generators/builder/Procfile.dev +1 -1
  21. data/lib/stealth/generators/builder/bot/controllers/catch_alls_controller.rb +3 -6
  22. data/lib/stealth/generators/builder/config/services.yml +9 -10
  23. data/lib/stealth/generators/generate/flow/controllers/controller.tt +0 -19
  24. data/lib/stealth/generators/generate/flow/helpers/helper.tt +2 -1
  25. data/lib/stealth/generators/generate/flow/replies/{ask_reply.tt → ask_example.tt} +0 -0
  26. data/lib/stealth/generators/generate.rb +2 -9
  27. data/lib/stealth/migrations/tasks.rb +2 -0
  28. data/lib/stealth/scheduled_reply.rb +1 -1
  29. data/lib/stealth/server.rb +2 -0
  30. data/lib/stealth/services/jobs/handle_message_job.rb +1 -1
  31. data/lib/stealth/session.rb +44 -16
  32. data/spec/controller/catch_all_spec.rb +2 -1
  33. data/spec/controller/controller_spec.rb +102 -13
  34. data/spec/controller/dynamic_delay_spec.rb +72 -0
  35. data/spec/controller/replies_spec.rb +94 -3
  36. data/spec/flow/state_spec.rb +33 -4
  37. data/spec/replies/messages/say_hola.yml+facebook.erb +6 -0
  38. data/spec/replies/messages/say_hola.yml+twilio.erb +6 -0
  39. data/spec/replies/messages/say_hola.yml.erb +6 -0
  40. data/spec/replies/messages/say_howdy_with_dynamic.yml +79 -0
  41. data/spec/replies/messages/say_offer_with_dynamic.yml +6 -0
  42. data/spec/replies/messages/say_yo.yml +6 -0
  43. data/spec/replies/messages/say_yo.yml+twitter +6 -0
  44. data/spec/session_spec.rb +17 -0
  45. data/spec/support/sample_messages.rb +24 -26
  46. data/stealth.gemspec +1 -3
  47. metadata +25 -41
  48. data/.github/ISSUE_TEMPLATE/bug_report.md +0 -35
  49. data/.github/ISSUE_TEMPLATE/feature_request.md +0 -17
  50. data/lib/stealth/generators/generate/flow/models/model.tt +0 -2
  51. data/lib/stealth/generators/generate/flow/replies/say_no_reply.tt +0 -2
  52. data/lib/stealth/generators/generate/flow/replies/say_yes_reply.tt +0 -2
@@ -16,15 +16,15 @@ module Stealth
16
16
  yaml_reply, preprocessor = action_replies
17
17
 
18
18
  service_reply = Stealth::ServiceReply.new(
19
- recipient_id: current_user_id,
19
+ recipient_id: current_session_id,
20
20
  yaml_reply: yaml_reply,
21
21
  preprocessor: preprocessor,
22
22
  context: binding
23
23
  )
24
24
 
25
- for reply in service_reply.replies do
25
+ service_reply.replies.each_with_index do |reply, i|
26
26
  handler = reply_handler.new(
27
- recipient_id: current_user_id,
27
+ recipient_id: current_session_id,
28
28
  reply: reply
29
29
  )
30
30
 
@@ -35,7 +35,18 @@ module Stealth
35
35
  # If this was a 'delay' type of reply, we insert the delay
36
36
  if reply.reply_type == 'delay'
37
37
  begin
38
- sleep_duration = Float(reply["duration"])
38
+ if reply['duration'] == 'dynamic'
39
+ m = Stealth.config.dynamic_delay_muliplier
40
+ duration = dynamic_delay(
41
+ service_replies: service_reply.replies,
42
+ position: i
43
+ )
44
+
45
+ sleep_duration = Stealth.config.dynamic_delay_muliplier * duration
46
+ else
47
+ sleep_duration = Float(reply['duration'])
48
+ end
49
+
39
50
  sleep(sleep_duration)
40
51
  rescue ArgumentError, TypeError
41
52
  raise(ArgumentError, 'Invalid duration specified. Duration must be a float')
@@ -68,25 +79,55 @@ module Stealth
68
79
  current_session.flow_string.underscore.pluralize
69
80
  end
70
81
 
71
- def action_replies
72
- reply_dir = [*self._replies_path, replies_folder]
73
- reply_filename = "#{current_session.state_string}.yml"
74
- reply_file_path = File.join(*reply_dir, reply_filename)
82
+ def reply_dir
83
+ [*self._replies_path, replies_folder]
84
+ end
85
+
86
+ def base_reply_filename
87
+ "#{current_session.state_string}.yml"
88
+ end
89
+
90
+ def reply_filenames
91
+ service_filename = [base_reply_filename, current_service].join('+')
92
+
93
+ # Service-specific filenames take precedance (returned first)
94
+ [service_filename, base_reply_filename]
95
+ end
96
+
97
+ def find_reply_and_preprocessor
75
98
  selected_preprocessor = :none
99
+ reply_file_path = File.join(*reply_dir, base_reply_filename)
100
+ service_reply_path = File.join(*reply_dir, reply_filenames.first)
101
+
102
+ # Check if the service_filename exists
103
+ # If so, we can skip checking for a preprocessor
104
+ if File.exist?(service_reply_path)
105
+ return service_reply_path, selected_preprocessor
106
+ end
76
107
 
108
+ # Cycles through possible preprocessor and variant combinations
109
+ # Early returns for performance
77
110
  for preprocessor in self.class._preprocessors do
78
- selected_filepath = File.join(*reply_dir, [reply_filename, preprocessor.to_s].join('.'))
79
- if File.exists?(selected_filepath)
80
- reply_file_path = selected_filepath
81
- selected_preprocessor = preprocessor
82
- break
111
+ for reply_filename in reply_filenames do
112
+ selected_filepath = File.join(*reply_dir, [reply_filename, preprocessor.to_s].join('.'))
113
+ if File.exist?(selected_filepath)
114
+ reply_file_path = selected_filepath
115
+ selected_preprocessor = preprocessor
116
+ return reply_file_path, selected_preprocessor
117
+ end
83
118
  end
84
119
  end
85
120
 
121
+ return reply_file_path, selected_preprocessor
122
+ end
123
+
124
+ def action_replies
125
+ reply_file_path, selected_preprocessor = find_reply_and_preprocessor
126
+
86
127
  begin
87
128
  file_contents = File.read(reply_file_path)
88
129
  rescue Errno::ENOENT
89
- raise(Stealth::Errors::ReplyNotFound, "Could not find a reply in #{reply_file_path}")
130
+ raise(Stealth::Errors::ReplyNotFound, "Could not find a reply in #{reply_dir}")
90
131
  end
91
132
 
92
133
  return file_contents, selected_preprocessor
@@ -12,7 +12,7 @@ module Stealth
12
12
 
13
13
  class_methods do
14
14
  def flow(flow_name, &specification)
15
- flow_spec[flow_name.to_sym] = Specification.new(&specification)
15
+ flow_spec[flow_name.to_sym] = Specification.new(flow_name, &specification)
16
16
  end
17
17
  end
18
18
 
@@ -4,10 +4,12 @@
4
4
  module Stealth
5
5
  module Flow
6
6
  class Specification
7
+ attr_reader :flow_name
7
8
  attr_accessor :states, :initial_state
8
9
 
9
- def initialize(&specification)
10
+ def initialize(flow_name, &specification)
10
11
  @states = Hash.new
12
+ @flow_name = flow_name
11
13
  instance_eval(&specification)
12
14
  end
13
15
 
@@ -17,16 +19,36 @@ module Stealth
17
19
 
18
20
  private
19
21
 
20
- def state(name, fails_to: nil)
21
- fail_state = nil
22
- if fails_to.present?
23
- fail_state = Stealth::Flow::State.new(fails_to, self)
24
- end
22
+ def state(name, fails_to: nil, redirects_to: nil)
23
+ fail_state = get_fail_or_redirect_state(fails_to)
24
+ redirect_state = get_fail_or_redirect_state(redirects_to)
25
+
26
+ new_state = Stealth::Flow::State.new(
27
+ name: name,
28
+ spec: self,
29
+ fails_to: fail_state,
30
+ redirects_to: redirect_state
31
+ )
25
32
 
26
- new_state = Stealth::Flow::State.new(name, self, fail_state)
27
33
  @initial_state = new_state if @states.empty?
28
34
  @states[name.to_sym] = new_state
29
- @scoped_state = new_state
35
+ end
36
+
37
+ def get_fail_or_redirect_state(specified_state)
38
+ if specified_state.present?
39
+ session = Stealth::Session.new
40
+
41
+ if Stealth::Session.is_a_session_string?(specified_state)
42
+ session.session = specified_state
43
+ else
44
+ session.session = Stealth::Session.canonical_session_slug(
45
+ flow: flow_name,
46
+ state: specified_state
47
+ )
48
+ end
49
+
50
+ return session
51
+ end
30
52
  end
31
53
 
32
54
  end
@@ -8,14 +8,19 @@ module Stealth
8
8
  include Comparable
9
9
 
10
10
  attr_accessor :name
11
- attr_reader :spec, :fails_to
11
+ attr_reader :spec, :fails_to, :redirects_to
12
12
 
13
- def initialize(name, spec, fails_to = nil)
14
- if fails_to.present? && !fails_to.is_a?(Stealth::Flow::State)
15
- raise(ArgumentError, 'fails_to state should be a Stealth::Flow::State')
13
+ def initialize(name:, spec:, fails_to: nil, redirects_to: nil)
14
+ if fails_to.present? && !fails_to.is_a?(Stealth::Session)
15
+ raise(ArgumentError, 'fails_to state should be a Stealth::Session')
16
16
  end
17
17
 
18
- @name, @spec, @fails_to = name, spec, fails_to
18
+ if redirects_to.present? && !redirects_to.is_a?(Stealth::Session)
19
+ raise(ArgumentError, 'redirects_to state should be a Stealth::Session')
20
+ end
21
+
22
+ @name, @spec = name, spec
23
+ @fails_to, @redirects_to = fails_to, redirects_to
19
24
  end
20
25
 
21
26
  def <=>(other_state)
@@ -1,6 +1,9 @@
1
1
  source 'https://rubygems.org'
2
2
 
3
3
  gem 'stealth', '~> 1.0'
4
+
5
+ gem 'railties', '~> 5.2'
6
+ gem 'activerecord', '~> 5.2'
4
7
  # Use sqlite3 as the database for Active Record
5
8
  gem 'sqlite3'
6
9
 
@@ -1,2 +1,2 @@
1
1
  web: bundle exec puma -C config/puma.rb
2
- sidekiq: bundle exec sidekiq -C config/sidekiq.yml -q webhooks -q default -r ./config/boot.rb
2
+ sidekiq: bundle exec sidekiq -C config/sidekiq.yml -q stealth_webhooks -q stealth_replies -r ./config/boot.rb
@@ -3,19 +3,16 @@ class CatchAllsController < BotController
3
3
  def level1
4
4
  send_replies
5
5
 
6
- if previous_session_specifies_fails_to?
7
- step_to flow: previous_session.flow_string, state: previous_state.to_s
6
+ if fail_session.present?
7
+ step_to session: fail_session
8
8
  else
9
9
  step_to session: previous_session - 2.states
10
10
  end
11
11
  end
12
12
 
13
13
  private
14
- def previous_session_specifies_fails_to?
15
- previous_state.present?
16
- end
17
14
 
18
- def previous_state
15
+ def fail_session
19
16
  previous_session.flow.current_state.fails_to
20
17
  end
21
18
 
@@ -8,17 +8,16 @@ default: &default
8
8
  # setup:
9
9
  # greeting: # Greetings are broken up by locale
10
10
  # - locale: default
11
- # text: "Welcome to the Stealth bot 🤖"
11
+ # text: "Welcome to my Facebook Bot."
12
+ # get_started:
13
+ # payload: new_user
12
14
  # persistent_menu:
13
- # - type: payload
14
- # text: Main Menu
15
- # payload: main_menu
16
- # - type: url
17
- # text: Visit our website
18
- # url: https://example.com
19
- # - type: call
20
- # text: Call us
21
- # payload: "+4155330000"
15
+ # - locale: default
16
+ # composer_input_disabled: false
17
+ # call_to_actions:
18
+ # - type: payload
19
+ # text: Some Button
20
+ # payload: some_button
22
21
  #
23
22
  # ===========================================
24
23
  # ======== Example SMS Service Setup ========
@@ -2,25 +2,6 @@ class <%= name.classify.pluralize %>Controller < BotController
2
2
 
3
3
  def ask_example
4
4
  send_replies
5
- update_session_to_next
6
- end
7
-
8
- def get_example
9
- if current_message.message == 'Yes'
10
- step_to state: 'say_yes_example'
11
- elsif current_message.message == 'No'
12
- step_to state: 'say_no_example'
13
- end
14
- end
15
-
16
- def say_yes_example
17
- send_replies
18
- update_session_to state: 'ask_example'
19
- end
20
-
21
- def say_no_example
22
- send_replies
23
- update_session_to state: 'ask_example'
24
5
  end
25
6
 
26
7
  end
@@ -1,2 +1,3 @@
1
- module <%= name.capitalize.underscore.camelize %>Helper < BotHelper
1
+ module <%= name.capitalize.underscore.camelize %>Helper
2
+
2
3
  end
@@ -21,23 +21,16 @@ module Stealth
21
21
 
22
22
  def create_replies
23
23
  # Sample Ask Reply
24
- template('replies/ask_reply.tt', "bot/replies/#{name.pluralize}/ask_example.yml")
25
- # Sample Say Replies
26
- template('replies/say_yes_reply.tt', "bot/replies/#{name.pluralize}/say_yes_example.yml")
27
- template('replies/say_no_reply.tt', "bot/replies/#{name.pluralize}/say_no_example.yml")
24
+ template('replies/ask_example.tt', "bot/replies/#{name.pluralize}/ask_example.yml.erb")
28
25
  end
29
26
 
30
27
  def create_helper
31
28
  template('helpers/helper.tt', "bot/helpers/#{name}_helper.rb")
32
29
  end
33
30
 
34
- def create_model
35
- template('models/model.tt', "bot/models/#{name}.rb")
36
- end
37
-
38
31
  def edit_flow_map
39
32
  inject_into_file "config/flow_map.rb", after: "include Stealth::Flow\n" do
40
- "\n\tflow :#{name} do\n\t\tstate :ask_example\n\t\tstate :get_example\n\t\tstate :say_yes_example\n\t\tstate :say_no_example\n\tend\n\n"
33
+ "\n\tflow :#{name} do\n\t\tstate :ask_example\n\tend\n"
41
34
  end
42
35
  end
43
36
 
@@ -17,6 +17,8 @@ module Stealth
17
17
  end
18
18
 
19
19
  def load_tasks
20
+ return unless defined?(ActiveRecord)
21
+
20
22
  configure
21
23
 
22
24
  Configurator.environments_config do |proxy|
@@ -4,7 +4,7 @@
4
4
  module Stealth
5
5
 
6
6
  class ScheduledReplyJob < Stealth::Jobs
7
- sidekiq_options queue: :webhooks, retry: false
7
+ sidekiq_options queue: :stealth_replies, retry: false
8
8
 
9
9
  def perform(service, user_id, flow, state)
10
10
  service_message = ServiceMessage.new(service: service)
@@ -45,6 +45,8 @@ module Stealth
45
45
  )
46
46
 
47
47
  dispatcher.coordinate
48
+
49
+ status 202
48
50
  end
49
51
 
50
52
  end
@@ -5,7 +5,7 @@ module Stealth
5
5
  module Services
6
6
 
7
7
  class HandleMessageJob < Stealth::Jobs
8
- sidekiq_options queue: :webhooks, retry: false
8
+ sidekiq_options queue: :stealth_webhooks, retry: false
9
9
 
10
10
  def perform(service, params, headers)
11
11
  dispatcher = Stealth::Dispatcher.new(
@@ -9,15 +9,18 @@ module Stealth
9
9
  attr_reader :flow, :state, :user_id, :previous
10
10
  attr_accessor :session
11
11
 
12
- def initialize(user_id:, previous: false)
12
+ def initialize(user_id: nil, previous: false)
13
13
  @user_id = user_id
14
14
  @previous = previous
15
15
 
16
- unless defined?($redis) && $redis.present?
17
- raise(Stealth::Errors::RedisNotConfigured, "Please make sure REDIS_URL is configured before using sessions")
16
+ if user_id.present?
17
+ unless defined?($redis) && $redis.present?
18
+ raise(Stealth::Errors::RedisNotConfigured, "Please make sure REDIS_URL is configured before using sessions")
19
+ end
20
+
21
+ get
18
22
  end
19
23
 
20
- get
21
24
  self
22
25
  end
23
26
 
@@ -47,10 +50,14 @@ module Stealth
47
50
  end
48
51
 
49
52
  def get
50
- if previous?
51
- @session ||= $redis.get(previous_session_key(user_id: user_id))
52
- else
53
- @session ||= $redis.get(user_id)
53
+ prev_key = previous_session_key(user_id: user_id)
54
+
55
+ @session ||= begin
56
+ if sessions_expire?
57
+ previous? ? getex(prev_key) : getex(user_id)
58
+ else
59
+ previous? ? $redis.get(prev_key) : $redis.get(user_id)
60
+ end
54
61
  end
55
62
  end
56
63
 
@@ -58,10 +65,15 @@ module Stealth
58
65
  store_current_to_previous(flow: flow, state: state)
59
66
 
60
67
  @flow = nil
61
- @session = canonical_session_slug(flow: flow, state: state)
68
+ @session = self.class.canonical_session_slug(flow: flow, state: state)
62
69
 
63
70
  Stealth::Logger.l(topic: "session", message: "User #{user_id}: setting session to #{flow}->#{state}")
64
- $redis.set(user_id, session)
71
+
72
+ if sessions_expire?
73
+ $redis.setex(user_id, Stealth.config.session_ttl, session)
74
+ else
75
+ $redis.set(user_id, session)
76
+ end
65
77
  end
66
78
 
67
79
  def present?
@@ -82,7 +94,7 @@ module Stealth
82
94
 
83
95
  new_state = self.state + steps
84
96
  new_session = Stealth::Session.new(user_id: self.user_id)
85
- new_session.session = canonical_session_slug(flow: self.flow_string, state: new_state)
97
+ new_session.session = self.class.canonical_session_slug(flow: self.flow_string, state: new_state)
86
98
 
87
99
  new_session
88
100
  end
@@ -97,18 +109,23 @@ module Stealth
97
109
  end
98
110
  end
99
111
 
100
- private
112
+ def self.is_a_session_string?(string)
113
+ session_regex = /(.+)(#{SLUG_SEPARATOR})(.+)/
114
+ !!string.match(session_regex)
115
+ end
101
116
 
102
- def canonical_session_slug(flow:, state:)
103
- [flow, state].join(SLUG_SEPARATOR)
104
- end
117
+ def self.canonical_session_slug(flow:, state:)
118
+ [flow, state].join(SLUG_SEPARATOR)
119
+ end
120
+
121
+ private
105
122
 
106
123
  def previous_session_key(user_id:)
107
124
  [user_id, 'previous'].join('-')
108
125
  end
109
126
 
110
127
  def store_current_to_previous(flow:, state:)
111
- new_session = canonical_session_slug(flow: flow, state: state)
128
+ new_session = self.class.canonical_session_slug(flow: flow, state: state)
112
129
 
113
130
  # Prevent previous_session from becoming current_session
114
131
  if new_session == session
@@ -119,5 +136,16 @@ module Stealth
119
136
  end
120
137
  end
121
138
 
139
+ def sessions_expire?
140
+ Stealth.config.session_ttl > 0
141
+ end
142
+
143
+ def getex(key)
144
+ $redis.multi do
145
+ $redis.expire(key, Stealth.config.session_ttl)
146
+ $redis.get(key)
147
+ end
148
+ end
149
+
122
150
  end
123
151
  end
@@ -62,12 +62,14 @@ describe "Stealth::Controller::CatchAll" do
62
62
  end
63
63
 
64
64
  it "should step_to catch_all->level1 when a StandardError is raised" do
65
+ controller.current_session.session = Stealth::Session.canonical_session_slug(flow: 'vader', state: 'my_action')
65
66
  controller.action(action: :my_action)
66
67
  expect(controller.current_session.flow_string).to eq("catch_all")
67
68
  expect(controller.current_session.state_string).to eq("level1")
68
69
  end
69
70
 
70
71
  it "should step_to catch_all->level1 when an action doesn't progress the flow" do
72
+ controller.current_session.session = Stealth::Session.canonical_session_slug(flow: 'vader', state: 'my_action2')
71
73
  controller.action(action: :my_action2)
72
74
  expect(controller.current_session.flow_string).to eq("catch_all")
73
75
  expect(controller.current_session.state_string).to eq("level1")
@@ -98,4 +100,3 @@ describe "Stealth::Controller::CatchAll" do
98
100
  end
99
101
  end
100
102
  end
101
-