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.
- checksums.yaml +4 -4
- data/.circleci/config.yml +18 -8
- data/CHANGELOG.md +100 -0
- data/Gemfile +1 -1
- data/Gemfile.lock +49 -43
- data/LICENSE +4 -17
- data/README.md +9 -17
- data/VERSION +1 -1
- data/lib/stealth/base.rb +62 -13
- data/lib/stealth/cli.rb +1 -2
- data/lib/stealth/commands/console.rb +1 -1
- data/lib/stealth/configuration.rb +0 -3
- data/lib/stealth/controller/callbacks.rb +1 -1
- data/lib/stealth/controller/catch_all.rb +27 -4
- data/lib/stealth/controller/controller.rb +168 -49
- data/lib/stealth/controller/dev_jumps.rb +41 -0
- data/lib/stealth/controller/dynamic_delay.rb +4 -6
- data/lib/stealth/controller/interrupt_detect.rb +100 -0
- data/lib/stealth/controller/messages.rb +283 -0
- data/lib/stealth/controller/nlp.rb +50 -0
- data/lib/stealth/controller/replies.rb +179 -41
- data/lib/stealth/controller/unrecognized_message.rb +62 -0
- data/lib/stealth/core_ext.rb +5 -0
- data/lib/stealth/{flow/core_ext.rb → core_ext/numeric.rb} +0 -1
- data/lib/stealth/core_ext/string.rb +18 -0
- data/lib/stealth/dispatcher.rb +21 -0
- data/lib/stealth/errors.rb +12 -0
- data/lib/stealth/flow/base.rb +1 -2
- data/lib/stealth/flow/specification.rb +3 -2
- data/lib/stealth/flow/state.rb +3 -3
- data/lib/stealth/generators/builder/Gemfile +4 -3
- data/lib/stealth/generators/builder/bot/controllers/bot_controller.rb +42 -0
- data/lib/stealth/generators/builder/bot/controllers/catch_alls_controller.rb +2 -0
- data/lib/stealth/generators/builder/bot/controllers/goodbyes_controller.rb +2 -0
- data/lib/stealth/generators/builder/bot/controllers/hellos_controller.rb +2 -0
- data/lib/stealth/generators/builder/bot/controllers/interrupts_controller.rb +9 -0
- data/lib/stealth/generators/builder/bot/controllers/unrecognized_messages_controller.rb +9 -0
- data/lib/stealth/generators/builder/config/flow_map.rb +8 -0
- data/lib/stealth/generators/builder/config/initializers/autoload.rb +8 -0
- data/lib/stealth/generators/builder/config/initializers/inflections.rb +16 -0
- data/lib/stealth/generators/builder/config/puma.rb +15 -0
- data/lib/stealth/helpers/redis.rb +40 -0
- data/lib/stealth/lock.rb +83 -0
- data/lib/stealth/logger.rb +27 -18
- data/lib/stealth/nlp/client.rb +22 -0
- data/lib/stealth/nlp/result.rb +57 -0
- data/lib/stealth/reloader.rb +90 -0
- data/lib/stealth/reply.rb +17 -0
- data/lib/stealth/scheduled_reply.rb +3 -3
- data/lib/stealth/server.rb +4 -4
- data/lib/stealth/service_message.rb +3 -2
- data/lib/stealth/service_reply.rb +5 -1
- data/lib/stealth/services/base_reply_handler.rb +2 -2
- data/lib/stealth/session.rb +106 -53
- data/spec/configuration_spec.rb +9 -2
- data/spec/controller/callbacks_spec.rb +23 -28
- data/spec/controller/catch_all_spec.rb +81 -29
- data/spec/controller/controller_spec.rb +444 -43
- data/spec/controller/dynamic_delay_spec.rb +16 -18
- data/spec/controller/helpers_spec.rb +1 -2
- data/spec/controller/interrupt_detect_spec.rb +171 -0
- data/spec/controller/messages_spec.rb +744 -0
- data/spec/controller/nlp_spec.rb +93 -0
- data/spec/controller/replies_spec.rb +446 -11
- data/spec/controller/unrecognized_message_spec.rb +168 -0
- data/spec/dispatcher_spec.rb +79 -0
- data/spec/flow/flow_spec.rb +1 -2
- data/spec/flow/state_spec.rb +14 -3
- data/spec/helpers/redis_spec.rb +77 -0
- data/spec/lock_spec.rb +100 -0
- data/spec/nlp/client_spec.rb +23 -0
- data/spec/nlp/result_spec.rb +57 -0
- data/spec/replies/messages/say_msgs_without_breaks.yml +4 -0
- data/spec/replies/messages/say_randomize_speech.yml +10 -0
- data/spec/replies/messages/say_randomize_text.yml +10 -0
- data/spec/replies/messages/sub1/sub2/say_nested.yml +10 -0
- data/spec/reply_spec.rb +61 -0
- data/spec/scheduled_reply_spec.rb +23 -0
- data/spec/service_reply_spec.rb +1 -2
- data/spec/session_spec.rb +251 -12
- data/spec/spec_helper.rb +21 -0
- data/spec/support/controllers/vaders_controller.rb +24 -0
- data/spec/support/nlp_clients/dialogflow.rb +9 -0
- data/spec/support/nlp_clients/luis.rb +9 -0
- data/spec/support/nlp_results/luis_result.rb +163 -0
- data/spec/version_spec.rb +1 -2
- data/stealth.gemspec +6 -6
- metadata +83 -39
- data/docs/00-introduction.md +0 -37
- data/docs/01-getting-started.md +0 -21
- data/docs/02-local-development.md +0 -40
- data/docs/03-basics.md +0 -171
- data/docs/04-sessions.md +0 -29
- data/docs/05-controllers.md +0 -179
- data/docs/06-models.md +0 -39
- data/docs/07-replies.md +0 -114
- data/docs/08-catchalls.md +0 -49
- data/docs/09-messaging-integrations.md +0 -80
- data/docs/10-nlp-integrations.md +0 -13
- data/docs/11-analytics.md +0 -13
- data/docs/12-commands.md +0 -62
- data/docs/13-deployment.md +0 -50
- 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
|
-
|
17
|
-
|
18
|
-
|
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
|
-
|
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:
|
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.
|
37
|
-
|
38
|
-
|
39
|
-
|
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
|
-
|
51
|
-
|
52
|
-
|
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
|
-
|
58
|
-
|
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
|
-
|
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
|
-
|
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,
|
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
|
-
|
100
|
-
|
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
|
112
|
-
selected_filepath = File.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
|
-
|
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(
|
206
|
+
file_contents = File.read(reply_path)
|
129
207
|
rescue Errno::ENOENT
|
130
|
-
raise(Stealth::Errors::ReplyNotFound, "Could not find
|
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
|