stealth 0.9.1
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 +7 -0
- data/.circleci/config.yml +58 -0
- data/.gitignore +12 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +81 -0
- data/LICENSE +20 -0
- data/README.md +1 -0
- data/VERSION +1 -0
- data/bin/stealth +5 -0
- data/lib/stealth/base.rb +87 -0
- data/lib/stealth/cli.rb +82 -0
- data/lib/stealth/cli_base.rb +25 -0
- data/lib/stealth/commands/command.rb +14 -0
- data/lib/stealth/commands/console.rb +75 -0
- data/lib/stealth/commands/server.rb +20 -0
- data/lib/stealth/configuration.rb +54 -0
- data/lib/stealth/controller.rb +190 -0
- data/lib/stealth/dispatcher.rb +48 -0
- data/lib/stealth/errors.rb +32 -0
- data/lib/stealth/flow/base.rb +256 -0
- data/lib/stealth/flow/errors.rb +25 -0
- data/lib/stealth/flow/event.rb +43 -0
- data/lib/stealth/flow/event_collection.rb +41 -0
- data/lib/stealth/flow/specification.rb +67 -0
- data/lib/stealth/flow/state.rb +48 -0
- data/lib/stealth/jobs.rb +10 -0
- data/lib/stealth/logger.rb +16 -0
- data/lib/stealth/reply.rb +19 -0
- data/lib/stealth/server.rb +38 -0
- data/lib/stealth/service_message.rb +17 -0
- data/lib/stealth/service_reply.rb +30 -0
- data/lib/stealth/services/base_client.rb +28 -0
- data/lib/stealth/services/base_message_handler.rb +28 -0
- data/lib/stealth/services/base_reply_handler.rb +65 -0
- data/lib/stealth/services/facebook/client.rb +35 -0
- data/lib/stealth/services/facebook/events/message_event.rb +59 -0
- data/lib/stealth/services/facebook/events/postback_event.rb +36 -0
- data/lib/stealth/services/facebook/message_handler.rb +84 -0
- data/lib/stealth/services/facebook/reply_handler.rb +471 -0
- data/lib/stealth/services/facebook/setup.rb +25 -0
- data/lib/stealth/services/jobs/handle_message_job.rb +22 -0
- data/lib/stealth/session.rb +74 -0
- data/lib/stealth/version.rb +12 -0
- data/lib/stealth.rb +1 -0
- data/spec/configuration_spec.rb +52 -0
- data/spec/flow/custom_transitions_spec.rb +99 -0
- data/spec/flow/flow_spec.rb +91 -0
- data/spec/flow/transition_callbacks_spec.rb +228 -0
- data/spec/replies/nested_reply_with_erb.yml +16 -0
- data/spec/sample_services_yml/services.yml +31 -0
- data/spec/sample_services_yml/services_with_erb.yml +31 -0
- data/spec/service_reply_spec.rb +34 -0
- data/spec/spec_helper.rb +13 -0
- data/spec/version_spec.rb +16 -0
- data/stealth.gemspec +30 -0
- metadata +247 -0
@@ -0,0 +1,190 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module Stealth
|
5
|
+
class Controller
|
6
|
+
|
7
|
+
include ActiveSupport::Callbacks
|
8
|
+
|
9
|
+
define_callbacks :action
|
10
|
+
|
11
|
+
attr_reader :current_message, :current_user_id, :current_flow,
|
12
|
+
:current_service, :flow_controller
|
13
|
+
|
14
|
+
def initialize(service_message:, current_flow: nil)
|
15
|
+
@current_message = service_message
|
16
|
+
@current_service = service_message.service
|
17
|
+
@current_user_id = service_message.sender_id
|
18
|
+
@current_flow = current_flow
|
19
|
+
end
|
20
|
+
|
21
|
+
def has_location?
|
22
|
+
current_message.location.present?
|
23
|
+
end
|
24
|
+
|
25
|
+
def has_attachments?
|
26
|
+
current_message.attachments.present?
|
27
|
+
end
|
28
|
+
|
29
|
+
def route
|
30
|
+
raise(Stealth::Errors::ControllerRoutingNotImplemented, "Please implement `route` method in BotController.")
|
31
|
+
end
|
32
|
+
|
33
|
+
def send_replies
|
34
|
+
service_reply = Stealth::ServiceReply.new(
|
35
|
+
recipient_id: current_user_id,
|
36
|
+
yaml_reply: action_replies,
|
37
|
+
context: binding
|
38
|
+
)
|
39
|
+
|
40
|
+
for reply in service_reply.replies do
|
41
|
+
handler = reply_handler.new(
|
42
|
+
recipient_id: current_user_id,
|
43
|
+
reply: reply
|
44
|
+
)
|
45
|
+
|
46
|
+
translated_reply = handler.send(reply.reply_type)
|
47
|
+
client = service_client.new(reply: translated_reply)
|
48
|
+
client.transmit
|
49
|
+
|
50
|
+
# If this was a 'delay' type of reply, let's respect the delay
|
51
|
+
if reply.reply_type == 'delay'
|
52
|
+
begin
|
53
|
+
sleep_duration = Float(reply["duration"])
|
54
|
+
sleep(sleep_duration)
|
55
|
+
rescue ArgumentError, TypeError
|
56
|
+
raise(ArgumentError, 'Invalid duration specified. Duration must be a float.')
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def flow_controller
|
63
|
+
@flow_controller = begin
|
64
|
+
flow_controller = [current_session.flow_string.pluralize, 'controller'].join('_').classify.constantize
|
65
|
+
flow_controller.new(
|
66
|
+
service_message: @current_message,
|
67
|
+
current_flow: current_flow
|
68
|
+
)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def current_session
|
73
|
+
@current_session ||= Stealth::Session.new(user_id: current_user_id)
|
74
|
+
end
|
75
|
+
|
76
|
+
def action(action: nil)
|
77
|
+
run_callbacks :action do
|
78
|
+
action ||= current_session.state_string
|
79
|
+
flow_controller.send(action)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def step_to(session: nil, flow: nil, state: nil)
|
84
|
+
flow, state = get_flow_and_state(session: session, flow: flow, state: state)
|
85
|
+
step(flow: flow, state: state)
|
86
|
+
end
|
87
|
+
|
88
|
+
def update_session_to(session: nil, flow: nil, state: nil)
|
89
|
+
flow, state = get_flow_and_state(session: session, flow: flow, state: state)
|
90
|
+
update_session(flow: flow, state: state)
|
91
|
+
end
|
92
|
+
|
93
|
+
def step_to_next
|
94
|
+
flow, state = get_next_state
|
95
|
+
step(flow: flow, state: state)
|
96
|
+
end
|
97
|
+
|
98
|
+
def update_session_to_next
|
99
|
+
flow, state = get_next_state
|
100
|
+
update_session(flow: flow, state: state)
|
101
|
+
end
|
102
|
+
|
103
|
+
def self.before_action(*args, &block)
|
104
|
+
set_callback(:action, :before, *args, &block)
|
105
|
+
end
|
106
|
+
|
107
|
+
def self.around_action(*args, &block)
|
108
|
+
set_callback(:action, :around, *args, &block)
|
109
|
+
end
|
110
|
+
|
111
|
+
def self.after_action(*args, &block)
|
112
|
+
set_callback(:action, :after, *args, &block)
|
113
|
+
end
|
114
|
+
|
115
|
+
private
|
116
|
+
|
117
|
+
def reply_handler
|
118
|
+
begin
|
119
|
+
Kernel.const_get("Stealth::Services::#{current_service.capitalize}::ReplyHandler")
|
120
|
+
rescue NameError
|
121
|
+
raise(Stealth::Errors::ServiceNotRecognized, "The service '#{current_service}' was not recognized.")
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
def service_client
|
126
|
+
begin
|
127
|
+
Kernel.const_get("Stealth::Services::#{current_service.capitalize}::Client")
|
128
|
+
rescue NameError
|
129
|
+
raise(Stealth::Errors::ServiceNotRecognized, "The service '#{current_service}' was not recognized.")
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
def replies_folder
|
134
|
+
current_session.flow_string.underscore.pluralize
|
135
|
+
end
|
136
|
+
|
137
|
+
def action_replies
|
138
|
+
File.read(File.join(Stealth.root, 'bot', 'replies', replies_folder, "#{current_session.state_string}.yml"))
|
139
|
+
end
|
140
|
+
|
141
|
+
def update_session(flow:, state:)
|
142
|
+
@current_session = Stealth::Session.new(user_id: current_user_id)
|
143
|
+
@current_session.set(flow: flow, state: state)
|
144
|
+
end
|
145
|
+
|
146
|
+
def step(flow:, state:)
|
147
|
+
update_session(flow: flow, state: state)
|
148
|
+
@current_flow = current_session.flow
|
149
|
+
|
150
|
+
action(action: state)
|
151
|
+
end
|
152
|
+
|
153
|
+
def get_flow_and_state(session: nil, flow: nil, state: nil)
|
154
|
+
if session.nil? && flow.nil? && state.nil?
|
155
|
+
raise(ArgumentError, "A session, flow, or state must be specified.")
|
156
|
+
end
|
157
|
+
|
158
|
+
if session.present?
|
159
|
+
return session.flow_string, session.state_string
|
160
|
+
end
|
161
|
+
|
162
|
+
if flow.present?
|
163
|
+
if state.blank?
|
164
|
+
flow_klass = [flow, 'flow'].join('_').classify.constantize
|
165
|
+
state = flow_klass.flow_spec.states.keys.first
|
166
|
+
end
|
167
|
+
|
168
|
+
return flow, state
|
169
|
+
end
|
170
|
+
|
171
|
+
if state.present?
|
172
|
+
return current_session.flow_string, state
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
def get_next_state
|
177
|
+
current_state_index = current_session.flow.states.index(current_session.state_string.to_sym)
|
178
|
+
next_state = current_session.flow.states[current_state_index + 1]
|
179
|
+
if next_state.nil?
|
180
|
+
raise(
|
181
|
+
Stealth::Errors::InvalidStateTransitions,
|
182
|
+
"The next state after #{current_session.state_string} has not yet been defined."
|
183
|
+
)
|
184
|
+
end
|
185
|
+
|
186
|
+
return current_session.flow_string, next_state
|
187
|
+
end
|
188
|
+
|
189
|
+
end
|
190
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module Stealth
|
5
|
+
|
6
|
+
# Responsible for coordinating incoming messages
|
7
|
+
# 1. Receives incoming request params
|
8
|
+
# 2. Initializes respective service request handler
|
9
|
+
# 3. Processes params through service request handler (might be async)
|
10
|
+
# 4. Inits base StealthController with state params returned from the service
|
11
|
+
# request handler
|
12
|
+
# 5. Returns an HTTP response to be returned to the requestor
|
13
|
+
class Dispatcher
|
14
|
+
|
15
|
+
attr_reader :service, :params, :headers, :message_handler
|
16
|
+
|
17
|
+
def initialize(service:, params:, headers:)
|
18
|
+
@service = service
|
19
|
+
@params = params
|
20
|
+
@headers = headers
|
21
|
+
@message_handler = message_handler_klass.new(
|
22
|
+
params: params,
|
23
|
+
headers: headers
|
24
|
+
)
|
25
|
+
end
|
26
|
+
|
27
|
+
def coordinate
|
28
|
+
message_handler.coordinate
|
29
|
+
end
|
30
|
+
|
31
|
+
def process
|
32
|
+
service_message = message_handler.process
|
33
|
+
bot_controller = BotController.new(service_message: service_message)
|
34
|
+
bot_controller.route
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def message_handler_klass
|
40
|
+
begin
|
41
|
+
Kernel.const_get("Stealth::Services::#{service.capitalize}::MessageHandler")
|
42
|
+
rescue NameError
|
43
|
+
raise(Stealth::Errors::ServiceNotRecognized, "The service '#{service}' was not recognized.")
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module Stealth
|
5
|
+
class Errors < StandardError
|
6
|
+
|
7
|
+
class ConfigurationError < Errors
|
8
|
+
end
|
9
|
+
|
10
|
+
class ReplyFormatNotSupported < Errors
|
11
|
+
end
|
12
|
+
|
13
|
+
class ServiceImpaired < Errors
|
14
|
+
end
|
15
|
+
|
16
|
+
class ServiceNotRecognized < Errors
|
17
|
+
end
|
18
|
+
|
19
|
+
class ControllerRoutingNotImplemented < Errors
|
20
|
+
end
|
21
|
+
|
22
|
+
class UndefinedVariable < Errors
|
23
|
+
end
|
24
|
+
|
25
|
+
class RedisNotConfigured < Errors
|
26
|
+
end
|
27
|
+
|
28
|
+
class InvalidStateTransition < Errors
|
29
|
+
end
|
30
|
+
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,256 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require 'stealth/flow/errors'
|
5
|
+
require 'stealth/flow/specification'
|
6
|
+
require 'stealth/flow/event_collection'
|
7
|
+
require 'stealth/flow/event'
|
8
|
+
require 'stealth/flow/state'
|
9
|
+
|
10
|
+
module Stealth
|
11
|
+
module Flow
|
12
|
+
|
13
|
+
module ClassMethods
|
14
|
+
attr_reader :flow_spec
|
15
|
+
|
16
|
+
def flow(&specification)
|
17
|
+
assign_flow Specification.new(Hash.new, &specification)
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
# Creates the convinience methods like `my_transition!`
|
23
|
+
def assign_flow(specification_object)
|
24
|
+
|
25
|
+
# Merging two workflow specifications can **not** be done automically, so
|
26
|
+
# just make the latest specification win. Same for inheritance -
|
27
|
+
# definition in the subclass wins.
|
28
|
+
if respond_to? :inherited_flow_spec # undefine methods defined by the old flow_spec
|
29
|
+
inherited_flow_spec.states.values.each do |state|
|
30
|
+
state_name = state.name
|
31
|
+
module_eval do
|
32
|
+
undef_method "#{state_name}?"
|
33
|
+
end
|
34
|
+
|
35
|
+
state.events.flat.each do |event|
|
36
|
+
event_name = event.name
|
37
|
+
module_eval do
|
38
|
+
undef_method "#{event_name}!".to_sym
|
39
|
+
undef_method "can_#{event_name}?"
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
@flow_spec = specification_object
|
46
|
+
@flow_spec.states.values.each do |state|
|
47
|
+
state_name = state.name
|
48
|
+
module_eval do
|
49
|
+
define_method "#{state_name}?" do
|
50
|
+
state_name == current_state.name
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
state.events.flat.each do |event|
|
55
|
+
event_name = event.name
|
56
|
+
module_eval do
|
57
|
+
define_method "#{event_name}!".to_sym do |*args|
|
58
|
+
process_event!(event_name, *args)
|
59
|
+
end
|
60
|
+
|
61
|
+
define_method "can_#{event_name}?" do
|
62
|
+
return !!current_state.events.first_applicable(event_name, self)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
module InstanceMethods
|
71
|
+
attr_accessor :flow_state, :user_id
|
72
|
+
|
73
|
+
def current_state
|
74
|
+
loaded_state = load_flow_state
|
75
|
+
res = spec.states[loaded_state.to_sym] if loaded_state
|
76
|
+
res || spec.initial_state
|
77
|
+
end
|
78
|
+
|
79
|
+
# Return true if the last transition was halted by one of the transition callbacks.
|
80
|
+
def halted?
|
81
|
+
@halted
|
82
|
+
end
|
83
|
+
|
84
|
+
# Return the reason of the last transition abort as set by the previous
|
85
|
+
# call of `halt` or `halt!` method.
|
86
|
+
def halted_because
|
87
|
+
@halted_because
|
88
|
+
end
|
89
|
+
|
90
|
+
def process_event!(name, *args)
|
91
|
+
event = current_state.events.first_applicable(name, self)
|
92
|
+
raise NoTransitionAllowed.new(
|
93
|
+
"There is no event #{name.to_sym} defined for the #{current_state} state") \
|
94
|
+
if event.nil?
|
95
|
+
@halted_because = nil
|
96
|
+
@halted = false
|
97
|
+
|
98
|
+
check_transition(event)
|
99
|
+
|
100
|
+
from = current_state
|
101
|
+
to = spec.states[event.transitions_to]
|
102
|
+
|
103
|
+
run_before_transition(from, to, name, *args)
|
104
|
+
return false if @halted
|
105
|
+
|
106
|
+
begin
|
107
|
+
return_value = run_action(event.action, *args) || run_action_callback(event.name, *args)
|
108
|
+
rescue StandardError => e
|
109
|
+
run_on_error(e, from, to, name, *args)
|
110
|
+
end
|
111
|
+
|
112
|
+
return false if @halted
|
113
|
+
|
114
|
+
run_on_transition(from, to, name, *args)
|
115
|
+
|
116
|
+
run_on_exit(from, to, name, *args)
|
117
|
+
|
118
|
+
transition_value = persist_flow_state(to.to_s)
|
119
|
+
|
120
|
+
run_on_entry(to, from, name, *args)
|
121
|
+
|
122
|
+
run_after_transition(from, to, name, *args)
|
123
|
+
|
124
|
+
return_value.nil? ? transition_value : return_value
|
125
|
+
end
|
126
|
+
|
127
|
+
def halt(reason = nil)
|
128
|
+
@halted_because = reason
|
129
|
+
@halted = true
|
130
|
+
end
|
131
|
+
|
132
|
+
def halt!(reason = nil)
|
133
|
+
@halted_because = reason
|
134
|
+
@halted = true
|
135
|
+
raise TransitionHalted.new(reason)
|
136
|
+
end
|
137
|
+
|
138
|
+
def spec
|
139
|
+
# check the singleton class first
|
140
|
+
class << self
|
141
|
+
return flow_spec if flow_spec
|
142
|
+
end
|
143
|
+
|
144
|
+
c = self.class
|
145
|
+
# using a simple loop instead of class_inheritable_accessor to avoid
|
146
|
+
# dependency on Rails' ActiveSupport
|
147
|
+
until c.flow_spec || !(c.include? Stealth::Flow)
|
148
|
+
c = c.superclass
|
149
|
+
end
|
150
|
+
c.flow_spec
|
151
|
+
end
|
152
|
+
|
153
|
+
def states
|
154
|
+
self.spec.states.keys
|
155
|
+
end
|
156
|
+
|
157
|
+
def init_state(state)
|
158
|
+
res = spec.states[state.to_sym] if state
|
159
|
+
@flow_state = res || spec.initial_state
|
160
|
+
self
|
161
|
+
end
|
162
|
+
|
163
|
+
private
|
164
|
+
|
165
|
+
def check_transition(event)
|
166
|
+
# Create a meaningful error message instead of
|
167
|
+
# "undefined method `on_entry' for nil:NilClass"
|
168
|
+
# Reported by Kyle Burton
|
169
|
+
if !spec.states[event.transitions_to]
|
170
|
+
raise StealthFlowError.new("Event[#{event.name}]'s " +
|
171
|
+
"transitions_to[#{event.transitions_to}] is not a declared state.")
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
def run_before_transition(from, to, event, *args)
|
176
|
+
instance_exec(from.name, to.name, event, *args, &spec.before_transition_proc) if spec.before_transition_proc
|
177
|
+
end
|
178
|
+
|
179
|
+
def run_on_error(error, from, to, event, *args)
|
180
|
+
if spec.on_error_proc
|
181
|
+
instance_exec(error, from.name, to.name, event, *args, &spec.on_error_proc)
|
182
|
+
halt(error.message)
|
183
|
+
else
|
184
|
+
raise error
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
def run_on_transition(from, to, event, *args)
|
189
|
+
instance_exec(from.name, to.name, event, *args, &spec.on_transition_proc) if spec.on_transition_proc
|
190
|
+
end
|
191
|
+
|
192
|
+
def run_after_transition(from, to, event, *args)
|
193
|
+
instance_exec(from.name, to.name, event, *args, &spec.after_transition_proc) if spec.after_transition_proc
|
194
|
+
end
|
195
|
+
|
196
|
+
def run_action(action, *args)
|
197
|
+
instance_exec(*args, &action) if action
|
198
|
+
end
|
199
|
+
|
200
|
+
def has_callback?(action)
|
201
|
+
# 1. public callback method or
|
202
|
+
# 2. protected method somewhere in the class hierarchy or
|
203
|
+
# 3. private in the immediate class (parent classes ignored)
|
204
|
+
action = action.to_sym
|
205
|
+
self.respond_to?(action) or
|
206
|
+
self.class.protected_method_defined?(action) or
|
207
|
+
self.private_methods(false).map(&:to_sym).include?(action)
|
208
|
+
end
|
209
|
+
|
210
|
+
def run_action_callback(action_name, *args)
|
211
|
+
action = action_name.to_sym
|
212
|
+
self.send(action, *args) if has_callback?(action)
|
213
|
+
end
|
214
|
+
|
215
|
+
def run_on_entry(state, prior_state, triggering_event, *args)
|
216
|
+
if state.on_entry
|
217
|
+
instance_exec(prior_state.name, triggering_event, *args, &state.on_entry)
|
218
|
+
else
|
219
|
+
hook_name = "on_#{state}_entry"
|
220
|
+
self.send(hook_name, prior_state, triggering_event, *args) if has_callback?(hook_name)
|
221
|
+
end
|
222
|
+
end
|
223
|
+
|
224
|
+
def run_on_exit(state, new_state, triggering_event, *args)
|
225
|
+
if state
|
226
|
+
if state.on_exit
|
227
|
+
instance_exec(new_state.name, triggering_event, *args, &state.on_exit)
|
228
|
+
else
|
229
|
+
hook_name = "on_#{state}_exit"
|
230
|
+
self.send(hook_name, new_state, triggering_event, *args) if has_callback?(hook_name)
|
231
|
+
end
|
232
|
+
end
|
233
|
+
end
|
234
|
+
|
235
|
+
def load_flow_state
|
236
|
+
@flow_state
|
237
|
+
end
|
238
|
+
|
239
|
+
def persist_flow_state(new_value)
|
240
|
+
@flow_state = new_value
|
241
|
+
if defined?($redis)
|
242
|
+
$redis.set(user_id, flow_and_state)
|
243
|
+
end
|
244
|
+
end
|
245
|
+
|
246
|
+
def flow_and_state
|
247
|
+
[self.class.to_s, current_state].join("->")
|
248
|
+
end
|
249
|
+
end
|
250
|
+
|
251
|
+
def self.included(klass)
|
252
|
+
klass.send :include, InstanceMethods
|
253
|
+
klass.extend ClassMethods
|
254
|
+
end
|
255
|
+
end
|
256
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module Stealth
|
5
|
+
module Flow
|
6
|
+
class Error < StandardError; end
|
7
|
+
|
8
|
+
class TransitionHalted < Error
|
9
|
+
|
10
|
+
attr_reader :halted_because
|
11
|
+
|
12
|
+
def initialize(msg = nil)
|
13
|
+
@halted_because = msg
|
14
|
+
super msg
|
15
|
+
end
|
16
|
+
|
17
|
+
end
|
18
|
+
|
19
|
+
class NoTransitionAllowed < Error; end
|
20
|
+
|
21
|
+
class StealthFlowError < Error; end
|
22
|
+
|
23
|
+
class StealthFlowDefinitionError < Error; end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module Stealth
|
5
|
+
module Flow
|
6
|
+
class Event
|
7
|
+
|
8
|
+
attr_accessor :name, :transitions_to, :meta, :action, :condition
|
9
|
+
|
10
|
+
def initialize(name, transitions_to, condition = nil, meta = {}, &action)
|
11
|
+
@name = name
|
12
|
+
@transitions_to = transitions_to.to_sym
|
13
|
+
@meta = meta
|
14
|
+
@action = action
|
15
|
+
@condition = if condition.nil? || condition.is_a?(Symbol) || condition.respond_to?(:call)
|
16
|
+
condition
|
17
|
+
else
|
18
|
+
raise TypeError, 'condition must be nil, an instance method name symbol or a callable (eg. a proc or lambda)'
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def condition_applicable?(object)
|
23
|
+
if condition
|
24
|
+
if condition.is_a?(Symbol)
|
25
|
+
object.send(condition)
|
26
|
+
else
|
27
|
+
condition.call(object)
|
28
|
+
end
|
29
|
+
else
|
30
|
+
true
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def draw(graph, from_state)
|
35
|
+
graph.add_edges(from_state.name.to_s, transitions_to.to_s, meta.merge(:label => to_s))
|
36
|
+
end
|
37
|
+
|
38
|
+
def to_s
|
39
|
+
@name.to_s
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module Stealth
|
5
|
+
module Flow
|
6
|
+
class EventCollection < Hash
|
7
|
+
|
8
|
+
def [](name)
|
9
|
+
super name.to_sym # Normalize to symbol
|
10
|
+
end
|
11
|
+
|
12
|
+
def push(name, event)
|
13
|
+
key = name.to_sym
|
14
|
+
self[key] ||= []
|
15
|
+
self[key] << event
|
16
|
+
end
|
17
|
+
|
18
|
+
def flat
|
19
|
+
self.values.flatten.uniq do |event|
|
20
|
+
[:name, :transitions_to, :meta, :action].map { |m| event.send(m) }
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def include?(name_or_obj)
|
25
|
+
case name_or_obj
|
26
|
+
when Event
|
27
|
+
flat.include? name_or_obj
|
28
|
+
else
|
29
|
+
!(self[name_or_obj].nil?)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def first_applicable(name, object_context)
|
34
|
+
(self[name] || []).detect do |event|
|
35
|
+
event.condition_applicable?(object_context) && event
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module Stealth
|
5
|
+
module Flow
|
6
|
+
class Specification
|
7
|
+
attr_accessor :states, :initial_state, :meta,
|
8
|
+
:on_transition_proc, :before_transition_proc, :after_transition_proc, :on_error_proc
|
9
|
+
|
10
|
+
def initialize(meta = {}, &specification)
|
11
|
+
@states = Hash.new
|
12
|
+
@meta = meta
|
13
|
+
instance_eval(&specification)
|
14
|
+
end
|
15
|
+
|
16
|
+
def state_names
|
17
|
+
states.keys
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def state(name, meta = {:meta => {}}, &events_and_etc)
|
23
|
+
# meta[:meta] to keep the API consistent..., gah
|
24
|
+
new_state = Stealth::Flow::State.new(name, self, meta[:meta])
|
25
|
+
@initial_state = new_state if @states.empty?
|
26
|
+
@states[name.to_sym] = new_state
|
27
|
+
@scoped_state = new_state
|
28
|
+
instance_eval(&events_and_etc) if events_and_etc
|
29
|
+
end
|
30
|
+
|
31
|
+
def event(name, args = {}, &action)
|
32
|
+
target = args[:transitions_to] || args[:transition_to]
|
33
|
+
condition = args[:if]
|
34
|
+
raise StealthFlowDefinitionError.new(
|
35
|
+
"missing ':transitions_to' in workflow event definition for '#{name}'") \
|
36
|
+
if target.nil?
|
37
|
+
@scoped_state.events.push(
|
38
|
+
name, Stealth::Flow::Event.new(name, target, condition, (args[:meta] or {}), &action)
|
39
|
+
)
|
40
|
+
end
|
41
|
+
|
42
|
+
def on_entry(&proc)
|
43
|
+
@scoped_state.on_entry = proc
|
44
|
+
end
|
45
|
+
|
46
|
+
def on_exit(&proc)
|
47
|
+
@scoped_state.on_exit = proc
|
48
|
+
end
|
49
|
+
|
50
|
+
def after_transition(&proc)
|
51
|
+
@after_transition_proc = proc
|
52
|
+
end
|
53
|
+
|
54
|
+
def before_transition(&proc)
|
55
|
+
@before_transition_proc = proc
|
56
|
+
end
|
57
|
+
|
58
|
+
def on_transition(&proc)
|
59
|
+
@on_transition_proc = proc
|
60
|
+
end
|
61
|
+
|
62
|
+
def on_error(&proc)
|
63
|
+
@on_error_proc = proc
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|