stealth 1.0.4 → 1.1.0.rc1

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