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
@@ -12,19 +12,61 @@ module Stealth
12
12
  class_attribute :_preprocessors, default: [:erb]
13
13
  class_attribute :_replies_path, default: [Stealth.root, 'bot', 'replies']
14
14
 
15
- def send_replies
16
- yaml_reply, preprocessor = action_replies
17
-
18
- service_reply = Stealth::ServiceReply.new(
19
- recipient_id: current_session_id,
20
- yaml_reply: yaml_reply,
21
- preprocessor: preprocessor,
22
- context: binding
15
+ def send_replies(custom_reply: nil, inline: nil)
16
+ service_reply = load_service_reply(
17
+ custom_reply: custom_reply,
18
+ inline: inline
23
19
  )
24
20
 
25
- service_reply.replies.each_with_index do |reply, i|
21
+ # Determine if we start at the beginning or somewhere else
22
+ reply_range = calculate_reply_range
23
+ offset = reply_range.first
24
+
25
+ @previous_reply = nil
26
+ service_reply.replies.slice(reply_range).each_with_index do |reply, i|
27
+ # Updates the lock with the current position of the reply
28
+ lock_session!(
29
+ session_slug: current_session.get_session,
30
+ position: i + offset # Otherwise this won't account for explicit starting points
31
+ )
32
+
33
+ begin
34
+ send_reply(reply: reply)
35
+ rescue Stealth::Errors::UserOptOut => e
36
+ user_opt_out_handler(msg: e.message)
37
+ return
38
+ rescue Stealth::Errors::InvalidSessionID => e
39
+ invalid_session_id_handler(msg: e.message)
40
+ return
41
+ end
42
+
43
+ @previous_reply = reply
44
+ end
45
+
46
+ @progressed = :sent_replies
47
+ ensure
48
+ release_lock!
49
+ end
50
+
51
+ private
52
+
53
+ def send_reply(reply:)
54
+ if !reply.delay? && Stealth.config.auto_insert_delays
55
+ # if it's the first reply in the service_reply or the previous reply
56
+ # wasn't a custom delay, then insert a delay
57
+ if @previous_reply.blank? || !@previous_reply.delay?
58
+ send_reply(reply: Reply.dynamic_delay)
59
+ end
60
+ end
61
+
62
+ # Support randomized replies for text and speech replies.
63
+ # We select one before handing the reply off to the driver.
64
+ if reply['text'].is_a?(Array)
65
+ reply['text'] = reply['text'].sample
66
+ end
67
+
26
68
  handler = reply_handler.new(
27
- recipient_id: current_session_id,
69
+ recipient_id: current_message.sender_id,
28
70
  reply: reply
29
71
  )
30
72
 
@@ -32,32 +74,49 @@ module Stealth
32
74
  client = service_client.new(reply: translated_reply)
33
75
  client.transmit
34
76
 
77
+ log_reply(reply) if Stealth.config.transcript_logging
78
+
35
79
  # If this was a 'delay' type of reply, we insert the delay
36
- if reply.reply_type == 'delay'
37
- begin
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
80
+ if reply.delay?
81
+ insert_delay(duration: reply['duration'])
82
+ end
83
+ end
49
84
 
50
- sleep(sleep_duration)
51
- rescue ArgumentError, TypeError
52
- raise(ArgumentError, 'Invalid duration specified. Duration must be a float')
85
+ def insert_delay(duration:)
86
+ begin
87
+ sleep_duration = if duration == 'dynamic'
88
+ dyn_duration = dynamic_delay(previous_reply: @previous_reply)
89
+
90
+ Stealth.config.dynamic_delay_muliplier * dyn_duration
91
+ else
92
+ Float(duration)
53
93
  end
94
+
95
+ sleep(sleep_duration)
96
+ rescue ArgumentError, TypeError
97
+ raise(ArgumentError, 'Invalid duration specified. Duration must be a Numeric')
54
98
  end
55
99
  end
56
100
 
57
- @progressed = :sent_replies
58
- end
101
+ def load_service_reply(custom_reply:, inline:)
102
+ if inline.present?
103
+ Stealth::ServiceReply.new(
104
+ recipient_id: current_session_id,
105
+ yaml_reply: inline,
106
+ preprocessor: :none,
107
+ context: nil
108
+ )
109
+ else
110
+ yaml_reply, preprocessor = action_replies(custom_reply)
59
111
 
60
- private
112
+ Stealth::ServiceReply.new(
113
+ recipient_id: current_session_id,
114
+ yaml_reply: yaml_reply,
115
+ preprocessor: preprocessor,
116
+ context: binding
117
+ )
118
+ end
119
+ end
61
120
 
62
121
  def service_client
63
122
  begin
@@ -87,17 +146,36 @@ module Stealth
87
146
  "#{current_session.state_string}.yml"
88
147
  end
89
148
 
90
- def reply_filenames
91
- service_filename = [base_reply_filename, current_service].join('+')
149
+ def reply_filenames(custom_reply_filename=nil)
150
+ reply_filename = if custom_reply_filename.present?
151
+ custom_reply_filename
152
+ else
153
+ base_reply_filename
154
+ end
155
+
156
+ service_filename = [reply_filename, current_service].join('+')
92
157
 
93
158
  # Service-specific filenames take precedance (returned first)
94
- [service_filename, base_reply_filename]
159
+ [service_filename, reply_filename]
95
160
  end
96
161
 
97
- def find_reply_and_preprocessor
162
+ def find_reply_and_preprocessor(custom_reply)
98
163
  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)
164
+
165
+ if custom_reply.present?
166
+ dir_and_file = custom_reply.rpartition(File::SEPARATOR)
167
+ _dir = dir_and_file.first
168
+ _file = "#{dir_and_file.last}.yml"
169
+ _replies_dir = [*self._replies_path, _dir]
170
+ possible_filenames = reply_filenames(_file)
171
+ reply_file_path = File.join(_replies_dir, _file)
172
+ service_reply_path = File.join(_replies_dir, reply_filenames(_file).first)
173
+ else
174
+ _replies_dir = *reply_dir
175
+ possible_filenames = reply_filenames
176
+ reply_file_path = File.join(_replies_dir, base_reply_filename)
177
+ service_reply_path = File.join(_replies_dir, reply_filenames.first)
178
+ end
101
179
 
102
180
  # Check if the service_filename exists
103
181
  # If so, we can skip checking for a preprocessor
@@ -108,8 +186,8 @@ module Stealth
108
186
  # Cycles through possible preprocessor and variant combinations
109
187
  # Early returns for performance
110
188
  for preprocessor in self.class._preprocessors do
111
- for reply_filename in reply_filenames do
112
- selected_filepath = File.join(*reply_dir, [reply_filename, preprocessor.to_s].join('.'))
189
+ for reply_filename in possible_filenames do
190
+ selected_filepath = File.join(_replies_dir, [reply_filename, preprocessor.to_s].join('.'))
113
191
  if File.exist?(selected_filepath)
114
192
  reply_file_path = selected_filepath
115
193
  selected_preprocessor = preprocessor
@@ -121,18 +199,82 @@ module Stealth
121
199
  return reply_file_path, selected_preprocessor
122
200
  end
123
201
 
124
- def action_replies
125
- reply_file_path, selected_preprocessor = find_reply_and_preprocessor
202
+ def action_replies(custom_reply=nil)
203
+ reply_path, selected_preprocessor = find_reply_and_preprocessor(custom_reply)
126
204
 
127
205
  begin
128
- file_contents = File.read(reply_file_path)
206
+ file_contents = File.read(reply_path)
129
207
  rescue Errno::ENOENT
130
- raise(Stealth::Errors::ReplyNotFound, "Could not find a reply in #{reply_dir}")
208
+ raise(Stealth::Errors::ReplyNotFound, "Could not find reply: '#{reply_path}'")
131
209
  end
132
210
 
133
211
  return file_contents, selected_preprocessor
134
212
  end
135
213
 
214
+ def user_opt_out_handler(msg:)
215
+ if self.respond_to?(:handle_opt_out, true)
216
+ self.send(:handle_opt_out)
217
+ Stealth::Logger.l(
218
+ topic: current_service,
219
+ message: "User #{current_session_id} opted out. [#{msg}]"
220
+ )
221
+ else
222
+ Stealth::Logger.l(
223
+ topic: :err,
224
+ message: "User #{current_session_id} unhandled exception due to opt-out."
225
+ )
226
+ end
227
+
228
+ do_nothing
229
+ end
230
+
231
+ def invalid_session_id_handler(msg:)
232
+ if self.respond_to?(:handle_invalid_session_id, true)
233
+ self.send(:handle_invalid_session_id)
234
+ Stealth::Logger.l(
235
+ topic: current_service,
236
+ message: "User #{current_session_id} has an invalid session_id. [#{msg}]"
237
+ )
238
+ else
239
+ Stealth::Logger.l(
240
+ topic: :err,
241
+ message: "User #{current_session_id} unhandled exception due to invalid session_id."
242
+ )
243
+ end
244
+
245
+ do_nothing
246
+ end
247
+
248
+ def calculate_reply_range
249
+ # if an explicit starting point is specified, use that until the
250
+ # end of the range, otherwise start at the beginning
251
+ if @pos.present?
252
+ (@pos..-1)
253
+ else
254
+ (0..-1)
255
+ end
256
+ end
257
+
258
+ def log_reply(reply)
259
+ message = case reply.reply_type
260
+ when 'text'
261
+ reply['text']
262
+ when 'speech'
263
+ reply['speech']
264
+ when 'ssml'
265
+ reply['ssml']
266
+ when 'delay'
267
+ '<typing indicator>'
268
+ else
269
+ "<#{reply.reply_type}>"
270
+ end
271
+
272
+ Stealth::Logger.l(
273
+ topic: current_service,
274
+ message: "User #{current_session_id} -> Sending: #{message}"
275
+ )
276
+ end
277
+
136
278
  end # instance methods
137
279
 
138
280
  end
@@ -0,0 +1,62 @@
1
+ # coding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module Stealth
5
+ class Controller
6
+ module UnrecognizedMessage
7
+
8
+ extend ActiveSupport::Concern
9
+
10
+ included do
11
+
12
+ def run_unrecognized_message(err:)
13
+ err_message = "The message \"#{current_message.message}\" was not recognized in the original context."
14
+
15
+ Stealth::Logger.l(
16
+ topic: 'unrecognized_message',
17
+ message: err_message
18
+ )
19
+
20
+ unless defined?(UnrecognizedMessagesController)
21
+ Stealth::Logger.l(
22
+ topic: 'unrecognized_message',
23
+ message: 'Running catch_all; UnrecognizedMessagesController not defined.'
24
+ )
25
+
26
+ run_catch_all(err: err)
27
+ return false
28
+ end
29
+
30
+ unrecognized_msg_controller = UnrecognizedMessagesController.new(
31
+ service_message: current_message
32
+ )
33
+
34
+ begin
35
+ # Run handle_unrecognized_message action
36
+ unrecognized_msg_controller.handle_unrecognized_message
37
+
38
+ if unrecognized_msg_controller.progressed?
39
+ Stealth::Logger.l(
40
+ topic: 'unrecognized_message',
41
+ message: 'A match was detected. Skipping catch-all.'
42
+ )
43
+ else
44
+ # Log, but we don't want to run the catch_all for a poorly
45
+ # coded UnrecognizedMessagesController
46
+ Stealth::Logger.l(
47
+ topic: 'unrecognized_message',
48
+ message: 'Did not send replies, update session, or step'
49
+ )
50
+ end
51
+ rescue StandardError => e
52
+ # Run the catch_all directly since we're already in an unrecognized
53
+ # message state
54
+ run_catch_all(err: e)
55
+ end
56
+ end
57
+
58
+ end
59
+
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ Dir.glob(File.expand_path('core_ext/*.rb', __dir__)).each do |path|
4
+ require path
5
+ end
@@ -1,4 +1,3 @@
1
- # coding: utf-8
2
1
  # frozen_string_literal: true
3
2
 
4
3
  class Numeric
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ class String
4
+
5
+ EXCLUDED_CHARS = %w[" ' . , ! ? ( ) - _ ` ‘ ’ “ ”].freeze
6
+ EXCLUDED_CHARS_ESC = EXCLUDED_CHARS.map { |c| "\\#{c}" }
7
+ EXCLUDED_CHARS_RE = /#{EXCLUDED_CHARS_ESC.join('|')}/
8
+
9
+ # Removes blank padding and double+single quotes
10
+ def normalize
11
+ self.upcase.strip
12
+ end
13
+
14
+ def without_punctuation
15
+ self.gsub(EXCLUDED_CHARS_RE, '')
16
+ end
17
+
18
+ end
@@ -30,6 +30,11 @@ module Stealth
30
30
 
31
31
  def process
32
32
  service_message = message_handler.process
33
+
34
+ if Stealth.config.transcript_logging
35
+ log_incoming_message(service_message)
36
+ end
37
+
33
38
  bot_controller = BotController.new(service_message: service_message)
34
39
  bot_controller.route
35
40
  end
@@ -44,5 +49,21 @@ module Stealth
44
49
  end
45
50
  end
46
51
 
52
+ def log_incoming_message(service_message)
53
+ message = if service_message.location.present?
54
+ "Received: <user shared location>"
55
+ elsif service_message.attachments.present?
56
+ "Received: <user sent attachment>"
57
+ elsif service_message.payload.present?
58
+ "Received Payload: #{service_message.payload}"
59
+ else
60
+ "Received Message: #{service_message.message}"
61
+ end
62
+
63
+ Stealth::Logger.l(
64
+ topic: 'user',
65
+ message: "User #{service_message.sender_id} -> #{message}"
66
+ )
67
+ end
47
68
  end
48
69
  end
@@ -34,11 +34,23 @@ module Stealth
34
34
  class ReplyNotFound < Errors
35
35
  end
36
36
 
37
+ class UnrecognizedMessage < Errors
38
+ end
39
+
37
40
  class FlowError < Errors
38
41
  end
39
42
 
40
43
  class FlowDefinitionError < Errors
41
44
  end
42
45
 
46
+ class InvalidSessionID < Errors
47
+ end
48
+
49
+ class UserOptOut < Errors
50
+ end
51
+
52
+ class ReservedHomophoneUsed < Errors
53
+ end
54
+
43
55
  end
44
56
  end
@@ -1,7 +1,6 @@
1
1
  # coding: utf-8
2
2
  # frozen_string_literal: true
3
3
 
4
- require 'stealth/flow/core_ext'
5
4
  require 'stealth/flow/specification'
6
5
  require 'stealth/flow/state'
7
6
 
@@ -55,7 +54,7 @@ module Stealth
55
54
  private
56
55
 
57
56
  def flow_and_state
58
- [current_flow, current_state].join("->")
57
+ [current_flow, current_state].join(Stealth::Session::SLUG_SEPARATOR)
59
58
  end
60
59
 
61
60
  def state_exists?(potential_flow:, potential_state:)