stealth 1.0.4 → 1.1.0.rc1
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/CHANGELOG.md +19 -4
- data/Gemfile +1 -1
- data/Gemfile.lock +32 -77
- data/README.md +1 -0
- data/VERSION +1 -1
- data/docs/03-basics.md +18 -2
- data/docs/04-sessions.md +12 -0
- data/docs/05-controllers.md +3 -3
- data/docs/07-replies.md +42 -1
- data/lib/stealth/base.rb +23 -9
- data/lib/stealth/controller/catch_all.rb +1 -1
- data/lib/stealth/controller/controller.rb +21 -9
- data/lib/stealth/controller/dynamic_delay.rb +64 -0
- data/lib/stealth/controller/replies.rb +55 -14
- data/lib/stealth/flow/base.rb +1 -1
- data/lib/stealth/flow/specification.rb +30 -8
- data/lib/stealth/flow/state.rb +10 -5
- data/lib/stealth/generators/builder/Gemfile +3 -0
- data/lib/stealth/generators/builder/Procfile.dev +1 -1
- data/lib/stealth/generators/builder/bot/controllers/catch_alls_controller.rb +3 -6
- data/lib/stealth/generators/builder/config/services.yml +9 -10
- data/lib/stealth/generators/generate/flow/controllers/controller.tt +0 -19
- data/lib/stealth/generators/generate/flow/helpers/helper.tt +2 -1
- data/lib/stealth/generators/generate/flow/replies/{ask_reply.tt → ask_example.tt} +0 -0
- data/lib/stealth/generators/generate.rb +2 -9
- data/lib/stealth/migrations/tasks.rb +2 -0
- data/lib/stealth/scheduled_reply.rb +1 -1
- data/lib/stealth/server.rb +2 -0
- data/lib/stealth/services/jobs/handle_message_job.rb +1 -1
- data/lib/stealth/session.rb +44 -16
- data/spec/controller/catch_all_spec.rb +2 -1
- data/spec/controller/controller_spec.rb +102 -13
- data/spec/controller/dynamic_delay_spec.rb +72 -0
- data/spec/controller/replies_spec.rb +94 -3
- data/spec/flow/state_spec.rb +33 -4
- data/spec/replies/messages/say_hola.yml+facebook.erb +6 -0
- data/spec/replies/messages/say_hola.yml+twilio.erb +6 -0
- data/spec/replies/messages/say_hola.yml.erb +6 -0
- data/spec/replies/messages/say_howdy_with_dynamic.yml +79 -0
- data/spec/replies/messages/say_offer_with_dynamic.yml +6 -0
- data/spec/replies/messages/say_yo.yml +6 -0
- data/spec/replies/messages/say_yo.yml+twitter +6 -0
- data/spec/session_spec.rb +17 -0
- data/spec/support/sample_messages.rb +24 -26
- data/stealth.gemspec +1 -3
- metadata +25 -41
- data/.github/ISSUE_TEMPLATE/bug_report.md +0 -35
- data/.github/ISSUE_TEMPLATE/feature_request.md +0 -17
- data/lib/stealth/generators/generate/flow/models/model.tt +0 -2
- data/lib/stealth/generators/generate/flow/replies/say_no_reply.tt +0 -2
- data/lib/stealth/generators/generate/flow/replies/say_yes_reply.tt +0 -2
@@ -16,15 +16,15 @@ module Stealth
|
|
16
16
|
yaml_reply, preprocessor = action_replies
|
17
17
|
|
18
18
|
service_reply = Stealth::ServiceReply.new(
|
19
|
-
recipient_id:
|
19
|
+
recipient_id: current_session_id,
|
20
20
|
yaml_reply: yaml_reply,
|
21
21
|
preprocessor: preprocessor,
|
22
22
|
context: binding
|
23
23
|
)
|
24
24
|
|
25
|
-
|
25
|
+
service_reply.replies.each_with_index do |reply, i|
|
26
26
|
handler = reply_handler.new(
|
27
|
-
recipient_id:
|
27
|
+
recipient_id: current_session_id,
|
28
28
|
reply: reply
|
29
29
|
)
|
30
30
|
|
@@ -35,7 +35,18 @@ module Stealth
|
|
35
35
|
# If this was a 'delay' type of reply, we insert the delay
|
36
36
|
if reply.reply_type == 'delay'
|
37
37
|
begin
|
38
|
-
|
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
|
49
|
+
|
39
50
|
sleep(sleep_duration)
|
40
51
|
rescue ArgumentError, TypeError
|
41
52
|
raise(ArgumentError, 'Invalid duration specified. Duration must be a float')
|
@@ -68,25 +79,55 @@ module Stealth
|
|
68
79
|
current_session.flow_string.underscore.pluralize
|
69
80
|
end
|
70
81
|
|
71
|
-
def
|
72
|
-
|
73
|
-
|
74
|
-
|
82
|
+
def reply_dir
|
83
|
+
[*self._replies_path, replies_folder]
|
84
|
+
end
|
85
|
+
|
86
|
+
def base_reply_filename
|
87
|
+
"#{current_session.state_string}.yml"
|
88
|
+
end
|
89
|
+
|
90
|
+
def reply_filenames
|
91
|
+
service_filename = [base_reply_filename, current_service].join('+')
|
92
|
+
|
93
|
+
# Service-specific filenames take precedance (returned first)
|
94
|
+
[service_filename, base_reply_filename]
|
95
|
+
end
|
96
|
+
|
97
|
+
def find_reply_and_preprocessor
|
75
98
|
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)
|
101
|
+
|
102
|
+
# Check if the service_filename exists
|
103
|
+
# If so, we can skip checking for a preprocessor
|
104
|
+
if File.exist?(service_reply_path)
|
105
|
+
return service_reply_path, selected_preprocessor
|
106
|
+
end
|
76
107
|
|
108
|
+
# Cycles through possible preprocessor and variant combinations
|
109
|
+
# Early returns for performance
|
77
110
|
for preprocessor in self.class._preprocessors do
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
111
|
+
for reply_filename in reply_filenames do
|
112
|
+
selected_filepath = File.join(*reply_dir, [reply_filename, preprocessor.to_s].join('.'))
|
113
|
+
if File.exist?(selected_filepath)
|
114
|
+
reply_file_path = selected_filepath
|
115
|
+
selected_preprocessor = preprocessor
|
116
|
+
return reply_file_path, selected_preprocessor
|
117
|
+
end
|
83
118
|
end
|
84
119
|
end
|
85
120
|
|
121
|
+
return reply_file_path, selected_preprocessor
|
122
|
+
end
|
123
|
+
|
124
|
+
def action_replies
|
125
|
+
reply_file_path, selected_preprocessor = find_reply_and_preprocessor
|
126
|
+
|
86
127
|
begin
|
87
128
|
file_contents = File.read(reply_file_path)
|
88
129
|
rescue Errno::ENOENT
|
89
|
-
raise(Stealth::Errors::ReplyNotFound, "Could not find a reply in #{
|
130
|
+
raise(Stealth::Errors::ReplyNotFound, "Could not find a reply in #{reply_dir}")
|
90
131
|
end
|
91
132
|
|
92
133
|
return file_contents, selected_preprocessor
|
data/lib/stealth/flow/base.rb
CHANGED
@@ -4,10 +4,12 @@
|
|
4
4
|
module Stealth
|
5
5
|
module Flow
|
6
6
|
class Specification
|
7
|
+
attr_reader :flow_name
|
7
8
|
attr_accessor :states, :initial_state
|
8
9
|
|
9
|
-
def initialize(&specification)
|
10
|
+
def initialize(flow_name, &specification)
|
10
11
|
@states = Hash.new
|
12
|
+
@flow_name = flow_name
|
11
13
|
instance_eval(&specification)
|
12
14
|
end
|
13
15
|
|
@@ -17,16 +19,36 @@ module Stealth
|
|
17
19
|
|
18
20
|
private
|
19
21
|
|
20
|
-
def state(name, fails_to: nil)
|
21
|
-
fail_state =
|
22
|
-
|
23
|
-
|
24
|
-
|
22
|
+
def state(name, fails_to: nil, redirects_to: nil)
|
23
|
+
fail_state = get_fail_or_redirect_state(fails_to)
|
24
|
+
redirect_state = get_fail_or_redirect_state(redirects_to)
|
25
|
+
|
26
|
+
new_state = Stealth::Flow::State.new(
|
27
|
+
name: name,
|
28
|
+
spec: self,
|
29
|
+
fails_to: fail_state,
|
30
|
+
redirects_to: redirect_state
|
31
|
+
)
|
25
32
|
|
26
|
-
new_state = Stealth::Flow::State.new(name, self, fail_state)
|
27
33
|
@initial_state = new_state if @states.empty?
|
28
34
|
@states[name.to_sym] = new_state
|
29
|
-
|
35
|
+
end
|
36
|
+
|
37
|
+
def get_fail_or_redirect_state(specified_state)
|
38
|
+
if specified_state.present?
|
39
|
+
session = Stealth::Session.new
|
40
|
+
|
41
|
+
if Stealth::Session.is_a_session_string?(specified_state)
|
42
|
+
session.session = specified_state
|
43
|
+
else
|
44
|
+
session.session = Stealth::Session.canonical_session_slug(
|
45
|
+
flow: flow_name,
|
46
|
+
state: specified_state
|
47
|
+
)
|
48
|
+
end
|
49
|
+
|
50
|
+
return session
|
51
|
+
end
|
30
52
|
end
|
31
53
|
|
32
54
|
end
|
data/lib/stealth/flow/state.rb
CHANGED
@@ -8,14 +8,19 @@ module Stealth
|
|
8
8
|
include Comparable
|
9
9
|
|
10
10
|
attr_accessor :name
|
11
|
-
attr_reader :spec, :fails_to
|
11
|
+
attr_reader :spec, :fails_to, :redirects_to
|
12
12
|
|
13
|
-
def initialize(name
|
14
|
-
if fails_to.present? && !fails_to.is_a?(Stealth::
|
15
|
-
raise(ArgumentError, 'fails_to state should be a Stealth::
|
13
|
+
def initialize(name:, spec:, fails_to: nil, redirects_to: nil)
|
14
|
+
if fails_to.present? && !fails_to.is_a?(Stealth::Session)
|
15
|
+
raise(ArgumentError, 'fails_to state should be a Stealth::Session')
|
16
16
|
end
|
17
17
|
|
18
|
-
|
18
|
+
if redirects_to.present? && !redirects_to.is_a?(Stealth::Session)
|
19
|
+
raise(ArgumentError, 'redirects_to state should be a Stealth::Session')
|
20
|
+
end
|
21
|
+
|
22
|
+
@name, @spec = name, spec
|
23
|
+
@fails_to, @redirects_to = fails_to, redirects_to
|
19
24
|
end
|
20
25
|
|
21
26
|
def <=>(other_state)
|
@@ -1,2 +1,2 @@
|
|
1
1
|
web: bundle exec puma -C config/puma.rb
|
2
|
-
sidekiq: bundle exec sidekiq -C config/sidekiq.yml -q
|
2
|
+
sidekiq: bundle exec sidekiq -C config/sidekiq.yml -q stealth_webhooks -q stealth_replies -r ./config/boot.rb
|
@@ -3,19 +3,16 @@ class CatchAllsController < BotController
|
|
3
3
|
def level1
|
4
4
|
send_replies
|
5
5
|
|
6
|
-
if
|
7
|
-
step_to
|
6
|
+
if fail_session.present?
|
7
|
+
step_to session: fail_session
|
8
8
|
else
|
9
9
|
step_to session: previous_session - 2.states
|
10
10
|
end
|
11
11
|
end
|
12
12
|
|
13
13
|
private
|
14
|
-
def previous_session_specifies_fails_to?
|
15
|
-
previous_state.present?
|
16
|
-
end
|
17
14
|
|
18
|
-
def
|
15
|
+
def fail_session
|
19
16
|
previous_session.flow.current_state.fails_to
|
20
17
|
end
|
21
18
|
|
@@ -8,17 +8,16 @@ default: &default
|
|
8
8
|
# setup:
|
9
9
|
# greeting: # Greetings are broken up by locale
|
10
10
|
# - locale: default
|
11
|
-
# text: "Welcome to
|
11
|
+
# text: "Welcome to my Facebook Bot."
|
12
|
+
# get_started:
|
13
|
+
# payload: new_user
|
12
14
|
# persistent_menu:
|
13
|
-
# -
|
14
|
-
#
|
15
|
-
#
|
16
|
-
#
|
17
|
-
#
|
18
|
-
#
|
19
|
-
# - type: call
|
20
|
-
# text: Call us
|
21
|
-
# payload: "+4155330000"
|
15
|
+
# - locale: default
|
16
|
+
# composer_input_disabled: false
|
17
|
+
# call_to_actions:
|
18
|
+
# - type: payload
|
19
|
+
# text: Some Button
|
20
|
+
# payload: some_button
|
22
21
|
#
|
23
22
|
# ===========================================
|
24
23
|
# ======== Example SMS Service Setup ========
|
@@ -2,25 +2,6 @@ class <%= name.classify.pluralize %>Controller < BotController
|
|
2
2
|
|
3
3
|
def ask_example
|
4
4
|
send_replies
|
5
|
-
update_session_to_next
|
6
|
-
end
|
7
|
-
|
8
|
-
def get_example
|
9
|
-
if current_message.message == 'Yes'
|
10
|
-
step_to state: 'say_yes_example'
|
11
|
-
elsif current_message.message == 'No'
|
12
|
-
step_to state: 'say_no_example'
|
13
|
-
end
|
14
|
-
end
|
15
|
-
|
16
|
-
def say_yes_example
|
17
|
-
send_replies
|
18
|
-
update_session_to state: 'ask_example'
|
19
|
-
end
|
20
|
-
|
21
|
-
def say_no_example
|
22
|
-
send_replies
|
23
|
-
update_session_to state: 'ask_example'
|
24
5
|
end
|
25
6
|
|
26
7
|
end
|
File without changes
|
@@ -21,23 +21,16 @@ module Stealth
|
|
21
21
|
|
22
22
|
def create_replies
|
23
23
|
# Sample Ask Reply
|
24
|
-
template('replies/
|
25
|
-
# Sample Say Replies
|
26
|
-
template('replies/say_yes_reply.tt', "bot/replies/#{name.pluralize}/say_yes_example.yml")
|
27
|
-
template('replies/say_no_reply.tt', "bot/replies/#{name.pluralize}/say_no_example.yml")
|
24
|
+
template('replies/ask_example.tt', "bot/replies/#{name.pluralize}/ask_example.yml.erb")
|
28
25
|
end
|
29
26
|
|
30
27
|
def create_helper
|
31
28
|
template('helpers/helper.tt', "bot/helpers/#{name}_helper.rb")
|
32
29
|
end
|
33
30
|
|
34
|
-
def create_model
|
35
|
-
template('models/model.tt', "bot/models/#{name}.rb")
|
36
|
-
end
|
37
|
-
|
38
31
|
def edit_flow_map
|
39
32
|
inject_into_file "config/flow_map.rb", after: "include Stealth::Flow\n" do
|
40
|
-
"\n\tflow :#{name} do\n\t\tstate :ask_example\n\
|
33
|
+
"\n\tflow :#{name} do\n\t\tstate :ask_example\n\tend\n"
|
41
34
|
end
|
42
35
|
end
|
43
36
|
|
@@ -4,7 +4,7 @@
|
|
4
4
|
module Stealth
|
5
5
|
|
6
6
|
class ScheduledReplyJob < Stealth::Jobs
|
7
|
-
sidekiq_options queue: :
|
7
|
+
sidekiq_options queue: :stealth_replies, retry: false
|
8
8
|
|
9
9
|
def perform(service, user_id, flow, state)
|
10
10
|
service_message = ServiceMessage.new(service: service)
|
data/lib/stealth/server.rb
CHANGED
data/lib/stealth/session.rb
CHANGED
@@ -9,15 +9,18 @@ module Stealth
|
|
9
9
|
attr_reader :flow, :state, :user_id, :previous
|
10
10
|
attr_accessor :session
|
11
11
|
|
12
|
-
def initialize(user_id
|
12
|
+
def initialize(user_id: nil, previous: false)
|
13
13
|
@user_id = user_id
|
14
14
|
@previous = previous
|
15
15
|
|
16
|
-
|
17
|
-
|
16
|
+
if user_id.present?
|
17
|
+
unless defined?($redis) && $redis.present?
|
18
|
+
raise(Stealth::Errors::RedisNotConfigured, "Please make sure REDIS_URL is configured before using sessions")
|
19
|
+
end
|
20
|
+
|
21
|
+
get
|
18
22
|
end
|
19
23
|
|
20
|
-
get
|
21
24
|
self
|
22
25
|
end
|
23
26
|
|
@@ -47,10 +50,14 @@ module Stealth
|
|
47
50
|
end
|
48
51
|
|
49
52
|
def get
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
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
|
54
61
|
end
|
55
62
|
end
|
56
63
|
|
@@ -58,10 +65,15 @@ module Stealth
|
|
58
65
|
store_current_to_previous(flow: flow, state: state)
|
59
66
|
|
60
67
|
@flow = nil
|
61
|
-
@session = canonical_session_slug(flow: flow, state: state)
|
68
|
+
@session = self.class.canonical_session_slug(flow: flow, state: state)
|
62
69
|
|
63
70
|
Stealth::Logger.l(topic: "session", message: "User #{user_id}: setting session to #{flow}->#{state}")
|
64
|
-
|
71
|
+
|
72
|
+
if sessions_expire?
|
73
|
+
$redis.setex(user_id, Stealth.config.session_ttl, session)
|
74
|
+
else
|
75
|
+
$redis.set(user_id, session)
|
76
|
+
end
|
65
77
|
end
|
66
78
|
|
67
79
|
def present?
|
@@ -82,7 +94,7 @@ module Stealth
|
|
82
94
|
|
83
95
|
new_state = self.state + steps
|
84
96
|
new_session = Stealth::Session.new(user_id: self.user_id)
|
85
|
-
new_session.session = canonical_session_slug(flow: self.flow_string, state: new_state)
|
97
|
+
new_session.session = self.class.canonical_session_slug(flow: self.flow_string, state: new_state)
|
86
98
|
|
87
99
|
new_session
|
88
100
|
end
|
@@ -97,18 +109,23 @@ module Stealth
|
|
97
109
|
end
|
98
110
|
end
|
99
111
|
|
100
|
-
|
112
|
+
def self.is_a_session_string?(string)
|
113
|
+
session_regex = /(.+)(#{SLUG_SEPARATOR})(.+)/
|
114
|
+
!!string.match(session_regex)
|
115
|
+
end
|
101
116
|
|
102
|
-
|
103
|
-
|
104
|
-
|
117
|
+
def self.canonical_session_slug(flow:, state:)
|
118
|
+
[flow, state].join(SLUG_SEPARATOR)
|
119
|
+
end
|
120
|
+
|
121
|
+
private
|
105
122
|
|
106
123
|
def previous_session_key(user_id:)
|
107
124
|
[user_id, 'previous'].join('-')
|
108
125
|
end
|
109
126
|
|
110
127
|
def store_current_to_previous(flow:, state:)
|
111
|
-
new_session = canonical_session_slug(flow: flow, state: state)
|
128
|
+
new_session = self.class.canonical_session_slug(flow: flow, state: state)
|
112
129
|
|
113
130
|
# Prevent previous_session from becoming current_session
|
114
131
|
if new_session == session
|
@@ -119,5 +136,16 @@ module Stealth
|
|
119
136
|
end
|
120
137
|
end
|
121
138
|
|
139
|
+
def sessions_expire?
|
140
|
+
Stealth.config.session_ttl > 0
|
141
|
+
end
|
142
|
+
|
143
|
+
def getex(key)
|
144
|
+
$redis.multi do
|
145
|
+
$redis.expire(key, Stealth.config.session_ttl)
|
146
|
+
$redis.get(key)
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
122
150
|
end
|
123
151
|
end
|
@@ -62,12 +62,14 @@ describe "Stealth::Controller::CatchAll" do
|
|
62
62
|
end
|
63
63
|
|
64
64
|
it "should step_to catch_all->level1 when a StandardError is raised" do
|
65
|
+
controller.current_session.session = Stealth::Session.canonical_session_slug(flow: 'vader', state: 'my_action')
|
65
66
|
controller.action(action: :my_action)
|
66
67
|
expect(controller.current_session.flow_string).to eq("catch_all")
|
67
68
|
expect(controller.current_session.state_string).to eq("level1")
|
68
69
|
end
|
69
70
|
|
70
71
|
it "should step_to catch_all->level1 when an action doesn't progress the flow" do
|
72
|
+
controller.current_session.session = Stealth::Session.canonical_session_slug(flow: 'vader', state: 'my_action2')
|
71
73
|
controller.action(action: :my_action2)
|
72
74
|
expect(controller.current_session.flow_string).to eq("catch_all")
|
73
75
|
expect(controller.current_session.state_string).to eq("level1")
|
@@ -98,4 +100,3 @@ describe "Stealth::Controller::CatchAll" do
|
|
98
100
|
end
|
99
101
|
end
|
100
102
|
end
|
101
|
-
|