stealth 1.1.3 → 2.0.0.beta2

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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/.circleci/config.yml +15 -54
  3. data/CHANGELOG.md +93 -0
  4. data/Gemfile +1 -1
  5. data/Gemfile.lock +53 -48
  6. data/LICENSE +4 -17
  7. data/README.md +9 -17
  8. data/VERSION +1 -1
  9. data/lib/stealth/base.rb +72 -19
  10. data/lib/stealth/cli.rb +1 -2
  11. data/lib/stealth/commands/console.rb +1 -1
  12. data/lib/stealth/configuration.rb +6 -3
  13. data/lib/stealth/controller/callbacks.rb +1 -1
  14. data/lib/stealth/controller/catch_all.rb +27 -4
  15. data/lib/stealth/controller/controller.rb +168 -49
  16. data/lib/stealth/controller/dev_jumps.rb +41 -0
  17. data/lib/stealth/controller/dynamic_delay.rb +4 -6
  18. data/lib/stealth/controller/interrupt_detect.rb +100 -0
  19. data/lib/stealth/controller/messages.rb +283 -0
  20. data/lib/stealth/controller/nlp.rb +50 -0
  21. data/lib/stealth/controller/replies.rb +183 -41
  22. data/lib/stealth/controller/unrecognized_message.rb +62 -0
  23. data/lib/stealth/core_ext.rb +5 -0
  24. data/lib/stealth/{flow/core_ext.rb → core_ext/numeric.rb} +0 -1
  25. data/lib/stealth/core_ext/string.rb +18 -0
  26. data/lib/stealth/dispatcher.rb +21 -0
  27. data/lib/stealth/errors.rb +12 -0
  28. data/lib/stealth/flow/base.rb +1 -2
  29. data/lib/stealth/flow/specification.rb +3 -2
  30. data/lib/stealth/flow/state.rb +3 -3
  31. data/lib/stealth/generators/builder/Gemfile +4 -3
  32. data/lib/stealth/generators/builder/bot/controllers/bot_controller.rb +42 -0
  33. data/lib/stealth/generators/builder/bot/controllers/catch_alls_controller.rb +2 -0
  34. data/lib/stealth/generators/builder/bot/controllers/goodbyes_controller.rb +2 -0
  35. data/lib/stealth/generators/builder/bot/controllers/hellos_controller.rb +2 -0
  36. data/lib/stealth/generators/builder/bot/controllers/interrupts_controller.rb +9 -0
  37. data/lib/stealth/generators/builder/bot/controllers/unrecognized_messages_controller.rb +9 -0
  38. data/lib/stealth/generators/builder/config/flow_map.rb +8 -0
  39. data/lib/stealth/generators/builder/config/initializers/autoload.rb +8 -0
  40. data/lib/stealth/generators/builder/config/initializers/inflections.rb +16 -0
  41. data/lib/stealth/generators/builder/config/puma.rb +15 -0
  42. data/lib/stealth/helpers/redis.rb +40 -0
  43. data/lib/stealth/lock.rb +83 -0
  44. data/lib/stealth/logger.rb +27 -18
  45. data/lib/stealth/nlp/client.rb +22 -0
  46. data/lib/stealth/nlp/result.rb +57 -0
  47. data/lib/stealth/reloader.rb +90 -0
  48. data/lib/stealth/reply.rb +17 -0
  49. data/lib/stealth/scheduled_reply.rb +3 -3
  50. data/lib/stealth/server.rb +3 -3
  51. data/lib/stealth/service_message.rb +3 -2
  52. data/lib/stealth/service_reply.rb +5 -1
  53. data/lib/stealth/services/base_reply_handler.rb +10 -2
  54. data/lib/stealth/session.rb +106 -53
  55. data/spec/configuration_spec.rb +42 -2
  56. data/spec/controller/callbacks_spec.rb +23 -28
  57. data/spec/controller/catch_all_spec.rb +81 -29
  58. data/spec/controller/controller_spec.rb +444 -43
  59. data/spec/controller/dynamic_delay_spec.rb +16 -18
  60. data/spec/controller/helpers_spec.rb +1 -2
  61. data/spec/controller/interrupt_detect_spec.rb +171 -0
  62. data/spec/controller/messages_spec.rb +744 -0
  63. data/spec/controller/nlp_spec.rb +93 -0
  64. data/spec/controller/replies_spec.rb +446 -11
  65. data/spec/controller/unrecognized_message_spec.rb +168 -0
  66. data/spec/dispatcher_spec.rb +79 -0
  67. data/spec/flow/flow_spec.rb +1 -2
  68. data/spec/flow/state_spec.rb +14 -3
  69. data/spec/helpers/redis_spec.rb +77 -0
  70. data/spec/lock_spec.rb +100 -0
  71. data/spec/nlp/client_spec.rb +23 -0
  72. data/spec/nlp/result_spec.rb +57 -0
  73. data/spec/replies/messages/say_msgs_without_breaks.yml +4 -0
  74. data/spec/replies/messages/say_randomize_speech.yml +10 -0
  75. data/spec/replies/messages/say_randomize_text.yml +10 -0
  76. data/spec/replies/messages/sub1/sub2/say_nested.yml +10 -0
  77. data/spec/reply_spec.rb +61 -0
  78. data/spec/scheduled_reply_spec.rb +23 -0
  79. data/spec/service_reply_spec.rb +1 -2
  80. data/spec/session_spec.rb +251 -12
  81. data/spec/spec_helper.rb +21 -0
  82. data/spec/support/controllers/vaders_controller.rb +24 -0
  83. data/spec/support/nlp_clients/dialogflow.rb +9 -0
  84. data/spec/support/nlp_clients/luis.rb +9 -0
  85. data/spec/support/nlp_results/luis_result.rb +163 -0
  86. data/spec/version_spec.rb +1 -2
  87. data/stealth.gemspec +6 -6
  88. metadata +83 -38
  89. data/docs/00-introduction.md +0 -37
  90. data/docs/01-getting-started.md +0 -21
  91. data/docs/02-local-development.md +0 -40
  92. data/docs/03-basics.md +0 -171
  93. data/docs/04-sessions.md +0 -29
  94. data/docs/05-controllers.md +0 -179
  95. data/docs/06-models.md +0 -39
  96. data/docs/07-replies.md +0 -114
  97. data/docs/08-catchalls.md +0 -49
  98. data/docs/09-messaging-integrations.md +0 -80
  99. data/docs/10-nlp-integrations.md +0 -13
  100. data/docs/11-analytics.md +0 -13
  101. data/docs/12-commands.md +0 -62
  102. data/docs/13-deployment.md +0 -50
  103. data/lib/stealth/generators/builder/config/initializers/.keep +0 -0
@@ -0,0 +1,90 @@
1
+ # coding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'zeitwerk'
5
+
6
+ module Stealth
7
+ class Reloader
8
+
9
+ def initialize
10
+ @reloader = Class.new(ActiveSupport::Reloader)
11
+ @loader = Zeitwerk::Loader.new
12
+ # @loader.logger = method(:puts)
13
+ @loader
14
+ end
15
+
16
+ def load_bot!
17
+ load_autoload_paths!
18
+ enable_reloading!
19
+ enable_eager_load!
20
+ @loader.setup
21
+ end
22
+
23
+ def load_autoload_paths!
24
+ if Stealth.config.autoload_paths.present?
25
+ Stealth.config.autoload_paths.each do |autoload_path|
26
+ @loader.push_dir(autoload_path)
27
+ end
28
+
29
+ # Bot-specific ignores
30
+ Stealth.config.autoload_ignore_paths.each do |autoload_ignore_path|
31
+ @loader.ignore(autoload_ignore_path)
32
+ end
33
+
34
+ # Ignore setup files
35
+ @loader.ignore(File.join(Stealth.root, 'config', 'initializers'))
36
+ @loader.ignore(File.join(Stealth.root, 'config', 'boot.rb'))
37
+ @loader.ignore(File.join(Stealth.root, 'config', 'environment.rb'))
38
+ @loader.ignore(File.join(Stealth.root, 'config', 'puma.rb'))
39
+ end
40
+ end
41
+
42
+ def enable_eager_load!
43
+ if Stealth.config.eager_load
44
+ @loader.eager_load
45
+ Stealth::Logger.l(topic: 'stealth', message: 'Eager loading enabled.')
46
+ end
47
+ end
48
+
49
+ def enable_reloading!
50
+ if Stealth.config.hot_reload
51
+ @checker = ActiveSupport::EventedFileUpdateChecker.new([], files_to_watch) do
52
+ reload!
53
+ end
54
+
55
+ @loader.enable_reloading
56
+ Stealth::Logger.l(topic: 'stealth', message: 'Hot reloading enabled.')
57
+ end
58
+ end
59
+
60
+ # Only reloads if a change has been detected in one of the autoload files`
61
+ def reload
62
+ if Stealth.config.hot_reload
63
+ @checker.execute_if_updated
64
+ end
65
+ end
66
+
67
+ # Force reloads reglardless of filesystem changes
68
+ def reload!
69
+ if Stealth.config.hot_reload
70
+ @loader.reload
71
+ end
72
+ end
73
+
74
+ def call
75
+ @reloader.wrap do
76
+ reload
77
+ yield
78
+ end
79
+ end
80
+
81
+ private
82
+
83
+ def files_to_watch
84
+ Stealth.config.autoload_paths.map do |path|
85
+ [path, 'rb']
86
+ end.to_h
87
+ end
88
+
89
+ end
90
+ end
@@ -15,5 +15,22 @@ module Stealth
15
15
  @reply[key]
16
16
  end
17
17
 
18
+ def []=(key, value)
19
+ @reply[key] = value
20
+ end
21
+
22
+ def delay?
23
+ reply_type == 'delay'
24
+ end
25
+
26
+ def self.dynamic_delay
27
+ self.new(
28
+ unstructured_reply: {
29
+ 'reply_type' => 'delay',
30
+ 'duration' => 'dynamic'
31
+ }
32
+ )
33
+ end
34
+
18
35
  end
19
36
  end
@@ -6,12 +6,12 @@ module Stealth
6
6
  class ScheduledReplyJob < Stealth::Jobs
7
7
  sidekiq_options queue: :stealth_replies, retry: false
8
8
 
9
- def perform(service, user_id, flow, state)
9
+ def perform(service, user_id, flow, state, target_id=nil)
10
10
  service_message = ServiceMessage.new(service: service)
11
11
  service_message.sender_id = user_id
12
+ service_message.target_id = target_id
12
13
  controller = BotController.new(service_message: service_message)
13
- controller.update_session_to(flow: flow, state: state)
14
- controller.route
14
+ controller.step_to(flow: flow, state: state)
15
15
  end
16
16
  end
17
17
 
@@ -30,7 +30,7 @@ module Stealth
30
30
  end
31
31
 
32
32
  get_or_post '/incoming/:service' do
33
- Stealth::Logger.l(topic: "incoming", message: "Received webhook from #{params[:service]}")
33
+ Stealth::Logger.l(topic: params[:service], message: 'Received webhook.')
34
34
 
35
35
  # JSON params need to be parsed and added to the params
36
36
  if request.env['CONTENT_TYPE']&.match(/application\/json/i)
@@ -50,8 +50,8 @@ module Stealth
50
50
  private
51
51
 
52
52
  def get_helpers_from_request(request)
53
- request.env.reject do |header, value|
54
- header.match(/rack\.|puma\.|sinatra\./)
53
+ request.env.select do |header, value|
54
+ %w[HTTP_HOST].include?(header)
55
55
  end
56
56
  end
57
57
 
@@ -4,8 +4,9 @@
4
4
  module Stealth
5
5
  class ServiceMessage
6
6
 
7
- attr_accessor :sender_id, :timestamp, :service, :message, :location,
8
- :attachments, :payload, :referral
7
+ attr_accessor :sender_id, :target_id, :timestamp, :service, :message,
8
+ :location, :attachments, :payload, :referral, :nlp_result,
9
+ :catch_all_reason
9
10
 
10
11
  def initialize(service:)
11
12
  @service = service
@@ -18,7 +18,11 @@ module Stealth
18
18
  @yaml_reply
19
19
  end
20
20
 
21
- @replies = load_replies(YAML.load(processed_reply))
21
+ if yaml_reply.is_a?(Array)
22
+ @replies = load_replies(@yaml_reply)
23
+ else
24
+ @replies = load_replies(YAML.load(processed_reply))
25
+ end
22
26
  end
23
27
 
24
28
  private
@@ -8,8 +8,8 @@ module Stealth
8
8
  attr_reader :recipient_id, :reply
9
9
 
10
10
  def initialize(recipient_id:, reply:)
11
- @client = client
12
- @options = options
11
+ @recipient_id = recipient_id
12
+ @reply = reply
13
13
  end
14
14
 
15
15
  def text
@@ -60,6 +60,14 @@ module Stealth
60
60
  reply_format_not_supported(format: 'delay')
61
61
  end
62
62
 
63
+ def speech
64
+ reply_format_not_supported(format: 'speech')
65
+ end
66
+
67
+ def ssml
68
+ reply_format_not_supported(format: 'ssml')
69
+ end
70
+
63
71
  end
64
72
  end
65
73
  end
@@ -4,21 +4,30 @@
4
4
  module Stealth
5
5
  class Session
6
6
 
7
+ include Stealth::Redis
8
+
7
9
  SLUG_SEPARATOR = '->'
8
10
 
9
- attr_reader :flow, :state, :user_id, :previous
11
+ attr_reader :flow, :state, :id, :type
10
12
  attr_accessor :session
11
13
 
12
- def initialize(user_id: nil, previous: false)
13
- @user_id = user_id
14
- @previous = previous
14
+ # Session types:
15
+ # - :primary
16
+ # - :previous
17
+ # - :back_to
18
+ def initialize(id: nil, type: :primary)
19
+ @id = id
20
+ @type = type
15
21
 
16
- if user_id.present?
22
+ if id.present?
17
23
  unless defined?($redis) && $redis.present?
18
- raise(Stealth::Errors::RedisNotConfigured, "Please make sure REDIS_URL is configured before using sessions")
24
+ raise(
25
+ Stealth::Errors::RedisNotConfigured,
26
+ "Please make sure REDIS_URL is configured before using sessions"
27
+ )
19
28
  end
20
29
 
21
- get
30
+ get_session
22
31
  end
23
32
 
24
33
  self
@@ -31,6 +40,14 @@ module Stealth
31
40
  }
32
41
  end
33
42
 
43
+ def self.slugify(flow:, state:)
44
+ unless flow.present? && state.present?
45
+ raise(ArgumentError, 'A flow and state must be specified.')
46
+ end
47
+
48
+ [flow, state].join(SLUG_SEPARATOR)
49
+ end
50
+
34
51
  def flow
35
52
  return nil if flow_string.blank?
36
53
 
@@ -49,31 +66,32 @@ module Stealth
49
66
  session&.split(SLUG_SEPARATOR)&.last
50
67
  end
51
68
 
52
- def get
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
61
- end
69
+ def get_session
70
+ @session ||= get_key(session_key)
62
71
  end
63
72
 
64
- def set(flow:, state:)
65
- store_current_to_previous(flow: flow, state: state)
66
-
67
- @flow = nil
68
- @session = self.class.canonical_session_slug(flow: flow, state: state)
73
+ def set_session(new_flow:, new_state:)
74
+ @flow = nil # override @flow's memoization
75
+ existing_session = session # tmp backup for previous_session storage
76
+ @session = self.class.canonical_session_slug(
77
+ flow: new_flow,
78
+ state: new_state
79
+ )
80
+
81
+ Stealth::Logger.l(
82
+ topic: [type, 'session'].join('_'),
83
+ message: "User #{id}: setting session to #{new_flow}->#{new_state}"
84
+ )
85
+
86
+ if primary_session?
87
+ store_current_to_previous(existing_session: existing_session)
88
+ end
69
89
 
70
- Stealth::Logger.l(topic: "session", message: "User #{user_id}: setting session to #{flow}->#{state}")
90
+ persist_key(key: session_key, value: session)
91
+ end
71
92
 
72
- if sessions_expire?
73
- $redis.setex(user_id, Stealth.config.session_ttl, session)
74
- else
75
- $redis.set(user_id, session)
76
- end
93
+ def clear_session
94
+ $redis.del(session_key)
77
95
  end
78
96
 
79
97
  def present?
@@ -84,17 +102,16 @@ module Stealth
84
102
  !present?
85
103
  end
86
104
 
87
- def previous?
88
- @previous
89
- end
90
-
91
105
  def +(steps)
92
106
  return nil if flow.blank?
93
107
  return self if steps.zero?
94
108
 
95
109
  new_state = self.state + steps
96
- new_session = Stealth::Session.new(user_id: self.user_id)
97
- new_session.session = self.class.canonical_session_slug(flow: self.flow_string, state: new_state)
110
+ new_session = Stealth::Session.new(id: self.id)
111
+ new_session.session = self.class.canonical_session_slug(
112
+ flow: self.flow_string,
113
+ state: new_state
114
+ )
98
115
 
99
116
  new_session
100
117
  end
@@ -109,6 +126,13 @@ module Stealth
109
126
  end
110
127
  end
111
128
 
129
+ def ==(other_session)
130
+ self.flow_string == other_session.flow_string &&
131
+ self.state_string == other_session.state_string &&
132
+ self.type == other_session.type &&
133
+ self.id == other_session.id
134
+ end
135
+
112
136
  def self.is_a_session_string?(string)
113
137
  session_regex = /(.+)(#{SLUG_SEPARATOR})(.+)/
114
138
  !!string.match(session_regex)
@@ -118,32 +142,61 @@ module Stealth
118
142
  [flow, state].join(SLUG_SEPARATOR)
119
143
  end
120
144
 
121
- private
122
-
123
- def previous_session_key(user_id:)
124
- [user_id, 'previous'].join('-')
145
+ def session_key
146
+ case type
147
+ when :primary
148
+ id
149
+ when :previous
150
+ previous_session_key
151
+ when :back_to
152
+ back_to_key
125
153
  end
154
+ end
126
155
 
127
- def store_current_to_previous(flow:, state:)
128
- new_session = self.class.canonical_session_slug(flow: flow, state: state)
156
+ def primary_session?
157
+ type == :primary
158
+ end
129
159
 
130
- # Prevent previous_session from becoming current_session
131
- if new_session == session
132
- Stealth::Logger.l(topic: "previous_session", message: "User #{user_id}: skipping setting to #{session} because it is the same as current_session")
133
- else
134
- Stealth::Logger.l(topic: "previous_session", message: "User #{user_id}: setting to #{session}")
135
- $redis.set(previous_session_key(user_id: user_id), session)
136
- end
160
+ def previous_session?
161
+ type == :previous
162
+ end
163
+
164
+ def back_to_session?
165
+ type == :back_to
166
+ end
167
+
168
+ def to_s
169
+ [flow_string, state_string].join(SLUG_SEPARATOR)
170
+ end
171
+
172
+ private
173
+
174
+ def previous_session_key
175
+ [id, 'previous'].join('-')
137
176
  end
138
177
 
139
- def sessions_expire?
140
- Stealth.config.session_ttl > 0
178
+ def back_to_key
179
+ [id, 'back_to'].join('-')
141
180
  end
142
181
 
143
- def getex(key)
144
- $redis.multi do
145
- $redis.expire(key, Stealth.config.session_ttl)
146
- $redis.get(key)
182
+ def store_current_to_previous(existing_session:)
183
+ # Prevent previous_session from becoming current_session
184
+ if session == existing_session
185
+ Stealth::Logger.l(
186
+ topic: "previous_session",
187
+ message: "User #{id}: skipping setting to #{session}"\
188
+ ' because it is the same as current_session'
189
+ )
190
+ else
191
+ Stealth::Logger.l(
192
+ topic: "previous_session",
193
+ message: "User #{id}: setting to #{existing_session}"
194
+ )
195
+
196
+ persist_key(
197
+ key: previous_session_key,
198
+ value: existing_session
199
+ )
147
200
  end
148
201
  end
149
202
 
@@ -1,7 +1,6 @@
1
- # coding: utf-8
2
1
  # frozen_string_literal: true
3
2
 
4
- require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
3
+ require 'spec_helper'
5
4
 
6
5
  describe "Stealth::Configuration" do
7
6
 
@@ -29,6 +28,14 @@ describe "Stealth::Configuration" do
29
28
  it "should handle multiple keys at the root level" do
30
29
  expect(config.twilio_sms.account_sid).to eq parsed_config['twilio_sms']['account_sid']
31
30
  end
31
+
32
+ it "should return nil if the key is not present at the node" do
33
+ expect(config.twilio_sms.api_key).to be nil
34
+ end
35
+
36
+ it "should raise a NoMethodError when accessing multi-levels of missing nodes" do
37
+ expect { config.slack.api_key }.to raise_error(NoMethodError)
38
+ end
32
39
  end
33
40
 
34
41
  describe "config files with ERB" do
@@ -50,4 +57,37 @@ describe "Stealth::Configuration" do
50
57
  end
51
58
  end
52
59
 
60
+ describe "configuring with default values" do
61
+ let(:config) {
62
+ Stealth::Configuration.new(
63
+ { 'a' => nil, 'x' => 0, 'y' => false, 'z' => '' }
64
+ )
65
+ }
66
+
67
+ it 'should replace a nil value' do
68
+ config.set_default('a', 99)
69
+ expect(config.a).to eq 99
70
+ end
71
+
72
+ it 'should NOT replace a zero value' do
73
+ config.set_default('x', 99)
74
+ expect(config.x).to eq 0
75
+ end
76
+
77
+ it 'should NOT replace a false value' do
78
+ config.set_default('y', 99)
79
+ expect(config.y).to be false
80
+ end
81
+
82
+ it 'should NOT replace a blank string value' do
83
+ config.set_default('z', 99)
84
+ expect(config.z).to eq ''
85
+ end
86
+
87
+ it 'should replace a not-set key' do
88
+ config.set_default('zz', 99)
89
+ expect(config.zz).to eq 99
90
+ end
91
+ end
92
+
53
93
  end