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.
- checksums.yaml +4 -4
- data/.circleci/config.yml +15 -54
- data/CHANGELOG.md +93 -0
- data/Gemfile +1 -1
- data/Gemfile.lock +53 -48
- data/LICENSE +4 -17
- data/README.md +9 -17
- data/VERSION +1 -1
- data/lib/stealth/base.rb +72 -19
- data/lib/stealth/cli.rb +1 -2
- data/lib/stealth/commands/console.rb +1 -1
- data/lib/stealth/configuration.rb +6 -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 +183 -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 +3 -3
- data/lib/stealth/service_message.rb +3 -2
- data/lib/stealth/service_reply.rb +5 -1
- data/lib/stealth/services/base_reply_handler.rb +10 -2
- data/lib/stealth/session.rb +106 -53
- data/spec/configuration_spec.rb +42 -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 -38
- 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
@@ -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,82 @@ 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'
|
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,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
|
data/lib/stealth/dispatcher.rb
CHANGED
@@ -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
|
data/lib/stealth/errors.rb
CHANGED
@@ -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
|
data/lib/stealth/flow/base.rb
CHANGED
@@ -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:)
|