stealth 1.1.2 → 2.0.0.beta1

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 +18 -8
  3. data/CHANGELOG.md +100 -0
  4. data/Gemfile +1 -1
  5. data/Gemfile.lock +49 -43
  6. data/LICENSE +4 -17
  7. data/README.md +9 -17
  8. data/VERSION +1 -1
  9. data/lib/stealth/base.rb +62 -13
  10. data/lib/stealth/cli.rb +1 -2
  11. data/lib/stealth/commands/console.rb +1 -1
  12. data/lib/stealth/configuration.rb +0 -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 +179 -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 +4 -4
  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 +2 -2
  54. data/lib/stealth/session.rb +106 -53
  55. data/spec/configuration_spec.rb +9 -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 -39
  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,100 @@
1
+ # coding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module Stealth
5
+ class Controller
6
+ module InterruptDetect
7
+
8
+ extend ActiveSupport::Concern
9
+
10
+ include Stealth::Redis
11
+
12
+ included do
13
+
14
+ attr_reader :current_lock
15
+
16
+ def current_lock
17
+ @current_lock ||= Stealth::Lock.find_lock(
18
+ session_id: current_session_id
19
+ )
20
+ end
21
+
22
+ def run_interrupt_action
23
+ Stealth::Logger.l(
24
+ topic: 'interrupt',
25
+ message: "Interrupt detected for session #{current_session_id}"
26
+ )
27
+
28
+ unless defined?(InterruptsController)
29
+ Stealth::Logger.l(
30
+ topic: 'interrupt',
31
+ message: 'Ignoring interrupt; InterruptsController not defined.'
32
+ )
33
+
34
+ return false
35
+ end
36
+
37
+ interrupt_controller = InterruptsController.new(
38
+ service_message: current_message
39
+ )
40
+
41
+ begin
42
+ # Run say_interrupted action
43
+ interrupt_controller.say_interrupted
44
+
45
+ unless interrupt_controller.progressed?
46
+ # Log, but we cannot run the catch_all here
47
+ Stealth::Logger.l(
48
+ topic: 'interrupt',
49
+ message: 'Did not send replies, update session, or step'
50
+ )
51
+ end
52
+ rescue StandardError => e
53
+ # Log, but we cannot run the catch_all here
54
+ Stealth::Logger.l(
55
+ topic: 'interrupt',
56
+ message: [e.message, e.backtrace.join("\n")].join("\n")
57
+ )
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def interrupt_detected?
64
+ # No interruption if there isn't an existing lock for this session
65
+ return false if current_lock.blank?
66
+
67
+ # No interruption if we are in the same thread
68
+ return false if current_thread_has_control?
69
+
70
+ true
71
+ end
72
+
73
+ def current_thread_has_control?
74
+ current_lock.tid == Stealth.tid
75
+ end
76
+
77
+ def lock_session!(session_slug:, position: nil)
78
+ lock = Stealth::Lock.new(
79
+ session_id: current_session_id,
80
+ session_slug: session_slug,
81
+ position: position
82
+ )
83
+
84
+ lock.create
85
+ end
86
+
87
+ # Yields control to other threads to take action on this session
88
+ # by releasing the lock.
89
+ def release_lock!
90
+ # We don't want to release the lock from within InterruptsController
91
+ # otherwise the InterruptsController can get interrupted.
92
+ unless self.class.to_s == 'InterruptsController'
93
+ current_lock&.release
94
+ end
95
+ end
96
+ end
97
+
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,283 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stealth
4
+ class Controller
5
+ module Messages
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ attr_accessor :normalized_msg, :homophone_translated_msg
10
+
11
+ unless defined?(ALPHA_ORDINALS)
12
+ ALPHA_ORDINALS = ('A'..'Z').to_a.freeze
13
+ end
14
+
15
+ unless defined?(NO_MATCH)
16
+ NO_MATCH = 0xdeadbeef
17
+ end
18
+
19
+ unless defined?(HOMOPHONES)
20
+ HOMOPHONES = {
21
+ 'EH' => 'A',
22
+ 'BE' => 'B',
23
+ 'BEE' => 'B',
24
+ 'CEE' => 'C',
25
+ 'SEA' => 'C',
26
+ 'SEE' => 'C',
27
+ 'DEE' => 'D',
28
+ 'GEE' => 'G',
29
+ 'EYE' => 'I',
30
+ 'AYE' => 'I',
31
+ 'JAY' => 'J',
32
+ 'KAY' => 'K',
33
+ 'KAYE' => 'K',
34
+ 'OH' => 'O',
35
+ 'OWE' => 'O',
36
+ 'PEA' => 'P',
37
+ 'PEE' => 'P',
38
+ 'CUE' => 'Q',
39
+ 'QUEUE' => 'Q',
40
+ 'ARR' => 'R',
41
+ 'YOU' => 'U',
42
+ 'YEW' => 'U',
43
+ 'EX' => 'X',
44
+ 'WHY' => 'Y',
45
+ 'ZEE' => 'Z'
46
+ }
47
+ end
48
+
49
+ def normalized_msg
50
+ @normalized_msg ||= current_message.message.normalize
51
+ end
52
+
53
+ # Converts homophones into alpha-ordinals
54
+ def homophone_translated_msg
55
+ @homophone_translated_msg ||= begin
56
+ ord = normalized_msg.without_punctuation
57
+ if HOMOPHONES[ord].present?
58
+ HOMOPHONES[ord]
59
+ else
60
+ ord
61
+ end
62
+ end
63
+ end
64
+
65
+ # Hash for message and lambda pairs. If the message is matched, the
66
+ # lambda will be called.
67
+ #
68
+ # Example: {
69
+ # "100k" => proc { step_back }, "200k" => proc { step_to flow :hello }
70
+ # }
71
+ def handle_message(message_tuples)
72
+ match = NO_MATCH # dummy value since nils are used for matching
73
+
74
+ if reserved_homophones_used = contains_homophones?(message_tuples.keys)
75
+ raise(
76
+ Stealth::Errors::ReservedHomophoneUsed,
77
+ "Cannot use `#{reserved_homophones_used.join(', ')}`. Reserved for homophones."
78
+ )
79
+ end
80
+
81
+ # Before checking content, match against our ordinals
82
+ if idx = message_is_an_ordinal?
83
+ # find the value stored in the message tuple via the index
84
+ matched_value = message_tuples.keys[idx]
85
+ match = matched_value unless matched_value.nil?
86
+ end
87
+
88
+ if match == NO_MATCH
89
+ message_tuples.keys.each_with_index do |msg, i|
90
+ # intent detection
91
+ if msg.is_a?(Symbol)
92
+ perform_nlp! unless nlp_result.present?
93
+
94
+ if intent_matched?(msg)
95
+ match = msg
96
+ break
97
+ else
98
+ next
99
+ end
100
+ end
101
+
102
+ if msg.is_a?(Regexp)
103
+ if normalized_msg =~ msg
104
+ match = msg
105
+ break
106
+ else
107
+ next
108
+ end
109
+ end
110
+
111
+ # custom mismatch handler; any nil key results in a match
112
+ if msg.nil?
113
+ match = msg
114
+ break
115
+ end
116
+
117
+ # check if the normalized message matches exactly
118
+ if message_matches?(msg)
119
+ match = msg
120
+ break
121
+ end
122
+ end
123
+ end
124
+
125
+ if match != NO_MATCH
126
+ instance_eval(&message_tuples[match])
127
+ else
128
+ handle_mismatch(true)
129
+ end
130
+ end
131
+
132
+ # Matches the message or the oridinal value entered (via SMS)
133
+ # Ignores case and strips leading and trailing whitespace before matching.
134
+ def get_match(messages, raise_on_mismatch: true, fuzzy_match: true)
135
+ if reserved_homophones_used = contains_homophones?(messages)
136
+ raise(
137
+ Stealth::Errors::ReservedHomophoneUsed,
138
+ "Cannot use `#{reserved_homophones_used.join(', ')}`. Reserved for homophones."
139
+ )
140
+ end
141
+
142
+ # Before checking content, match against our ordinals
143
+ if idx = message_is_an_ordinal?
144
+ return messages[idx] unless messages[idx].nil?
145
+ end
146
+
147
+ messages.each_with_index do |msg, i|
148
+ # entity detection
149
+ if msg.is_a?(Symbol)
150
+ perform_nlp! unless nlp_result.present?
151
+
152
+ if match = entity_matched?(msg, fuzzy_match)
153
+ return match
154
+ else
155
+ next
156
+ end
157
+ end
158
+
159
+ # multi-entity detection
160
+ if msg.is_a?(Array)
161
+ perform_nlp! unless nlp_result.present?
162
+
163
+ if match = entities_matched?(msg, fuzzy_match)
164
+ return match
165
+ else
166
+ next
167
+ end
168
+ end
169
+
170
+ if message_matches?(msg)
171
+ return msg
172
+ end
173
+ end
174
+
175
+ handle_mismatch(raise_on_mismatch)
176
+ end
177
+
178
+ private
179
+
180
+ def handle_mismatch(raise_on_mismatch)
181
+ log_nlp_result unless Stealth.config.log_all_nlp_results # Already logged
182
+
183
+ if raise_on_mismatch
184
+ raise(
185
+ Stealth::Errors::UnrecognizedMessage,
186
+ "The reply '#{current_message.message}' was not recognized."
187
+ )
188
+ else
189
+ current_message.message
190
+ end
191
+ end
192
+
193
+ def contains_homophones?(arr)
194
+ arr = arr.map do |elem|
195
+ elem.normalize if elem.is_a?(String)
196
+ end.compact
197
+
198
+ homophones = arr & HOMOPHONES.keys
199
+ homophones.any? ? homophones : false
200
+ end
201
+
202
+ # Returns the index of the ordinal, nil if not found
203
+ def message_is_an_ordinal?
204
+ ALPHA_ORDINALS.index(homophone_translated_msg)
205
+ end
206
+
207
+ def message_matches?(msg)
208
+ normalized_msg == msg.upcase
209
+ end
210
+
211
+ def intent_matched?(intent)
212
+ nlp_result.intent == intent
213
+ end
214
+
215
+ def entity_matched?(entity, fuzzy_match)
216
+ if nlp_result.entities.has_key?(entity)
217
+ match_count = nlp_result.entities[entity].size
218
+ if match_count > 1 && !fuzzy_match
219
+ log_nlp_result unless Stealth.config.log_all_nlp_results # Already logged
220
+
221
+ raise(
222
+ Stealth::Errors::UnrecognizedMessage,
223
+ "Encountered #{match_count} entity matches of type #{entity.inspect} and expected 1. To allow, set fuzzy_match to true."
224
+ )
225
+ else
226
+ # For single entity matches, just return the value
227
+ # rather than a single-element array
228
+ matched_entity = nlp_result.entities[entity].first
229
+
230
+ # Custom LUIS List entities return a single element array for some
231
+ # reason
232
+ if matched_entity.is_a?(Array) && matched_entity.size == 1
233
+ matched_entity.first
234
+ else
235
+ matched_entity
236
+ end
237
+ end
238
+ end
239
+ end
240
+
241
+ def entities_matched?(entities, fuzzy_match)
242
+ nlp_entities = nlp_result.entities.deep_dup
243
+ results = []
244
+
245
+ entities.each do |entity|
246
+ # If we run out of matches for the entity type
247
+ # (or never had any to begin with)
248
+ return false if nlp_entities[entity].blank?
249
+
250
+ results << nlp_entities[entity].shift
251
+ end
252
+
253
+ # Check for leftover entities for the types we were looking for
254
+ unless fuzzy_match
255
+ entities.each do |entity|
256
+ unless nlp_entities[entity].blank?
257
+ log_nlp_result unless Stealth.config.log_all_nlp_results # Already logged
258
+ leftover_count = nlp_entities[entity].size
259
+ raise(
260
+ Stealth::Errors::UnrecognizedMessage,
261
+ "Encountered #{leftover_count} additional entity matches of type #{entity.inspect} for match #{entities.inspect}. To allow, set fuzzy_match to true."
262
+ )
263
+ end
264
+ end
265
+ end
266
+
267
+ results
268
+ end
269
+
270
+ def log_nlp_result
271
+ # Log the results from the nlp_result if NLP was performed
272
+ if nlp_result.present?
273
+ Stealth::Logger.l(
274
+ topic: :nlp,
275
+ message: "User #{current_session_id} -> NLP Result: #{nlp_result.parsed_result.inspect}"
276
+ )
277
+ end
278
+ end
279
+
280
+ end
281
+ end
282
+ end
283
+ end
@@ -0,0 +1,50 @@
1
+ # coding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ module Stealth
5
+ class Controller
6
+ module Nlp
7
+
8
+ extend ActiveSupport::Concern
9
+
10
+ included do
11
+ # Memoized in order to prevent multiple requests to the NLP provider
12
+ def perform_nlp!
13
+ Stealth::Logger.l(
14
+ topic: :nlp,
15
+ message: "User #{current_session_id} -> Performing NLP."
16
+ )
17
+
18
+ unless Stealth.config.nlp_integration.present?
19
+ raise Stealth::Errors::ConfigurationError, "An NLP integration has not yet been configured (Stealth.config.nlp_integration)"
20
+ end
21
+
22
+ @nlp_result ||= begin
23
+ nlp_client = nlp_client_klass.new
24
+ @nlp_result = @current_message.nlp_result = nlp_client.understand(
25
+ query: current_message.message
26
+ )
27
+
28
+ if Stealth.config.log_all_nlp_results
29
+ Stealth::Logger.l(
30
+ topic: :nlp,
31
+ message: "User #{current_session_id} -> NLP Result: #{@nlp_result.parsed_result.inspect}"
32
+ )
33
+ end
34
+
35
+ @nlp_result
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def nlp_client_klass
42
+ integration = Stealth.config.nlp_integration.to_s.titlecase
43
+ klass = "Stealth::Nlp::#{integration}::Client"
44
+ klass.classify.constantize
45
+ end
46
+ end
47
+
48
+ end
49
+ end
50
+ end
@@ -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,78 @@ 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', 'speech'
261
+ reply['text']
262
+ when 'delay'
263
+ '<typing indicator>'
264
+ else
265
+ "<#{reply.reply_type}>"
266
+ end
267
+
268
+ Stealth::Logger.l(
269
+ topic: current_service,
270
+ message: "User #{current_session_id} -> Sending: #{message}"
271
+ )
272
+ end
273
+
136
274
  end # instance methods
137
275
 
138
276
  end