conduit-ussd 0.1.0
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/MIT-LICENSE +20 -0
- data/README.md +426 -0
- data/Rakefile +2 -0
- data/app/assets/stylesheets/conduit/application.css +15 -0
- data/app/controllers/conduit/application_controller.rb +4 -0
- data/app/helpers/conduit/application_helper.rb +4 -0
- data/app/jobs/conduit/application_job.rb +4 -0
- data/app/jobs/conduit/save_session_job.rb +11 -0
- data/app/models/conduit/application_record.rb +5 -0
- data/app/models/conduit/session_record.rb +28 -0
- data/config/routes.rb +2 -0
- data/lib/conduit/configuration.rb +25 -0
- data/lib/conduit/display_builder.rb +58 -0
- data/lib/conduit/engine.rb +21 -0
- data/lib/conduit/flow.rb +54 -0
- data/lib/conduit/middleware/logging.rb +23 -0
- data/lib/conduit/middleware/session_tracking.rb +30 -0
- data/lib/conduit/middleware/throttling.rb +41 -0
- data/lib/conduit/middleware.rb +36 -0
- data/lib/conduit/providers/africas_talking.rb +39 -0
- data/lib/conduit/request_handler.rb +126 -0
- data/lib/conduit/response.rb +23 -0
- data/lib/conduit/router.rb +30 -0
- data/lib/conduit/session.rb +73 -0
- data/lib/conduit/session_store.rb +41 -0
- data/lib/conduit/state.rb +189 -0
- data/lib/conduit/validator.rb +55 -0
- data/lib/conduit/version.rb +3 -0
- data/lib/conduit.rb +31 -0
- data/lib/generators/conduit/install/install_generator.rb +41 -0
- data/lib/generators/conduit/install/templates/conduit.rb +26 -0
- data/lib/generators/conduit/install/templates/example_flow.rb +72 -0
- data/lib/generators/conduit/install/templates/ussd_controller.rb +11 -0
- data/lib/generators/conduit/migration/migration_generator.rb +21 -0
- data/lib/generators/conduit/migration/templates/create_conduit_sessions.rb +19 -0
- data/lib/tasks/conduit_tasks.rake +4 -0
- metadata +191 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Conduit
|
|
2
|
+
module Middleware
|
|
3
|
+
class Logging < Base
|
|
4
|
+
def initialize(app, logger: nil)
|
|
5
|
+
super(app)
|
|
6
|
+
@logger = logger || Conduit.configuration.logger
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(env)
|
|
10
|
+
start_time = Time.current
|
|
11
|
+
|
|
12
|
+
@logger.info "USSD Request: session_id=#{env[:session_id]} msisdn=#{env[:msisdn]} input=#{env[:input]}"
|
|
13
|
+
|
|
14
|
+
result = @app.call(env)
|
|
15
|
+
|
|
16
|
+
duration = ((Time.current - start_time) * 1000).round(2)
|
|
17
|
+
@logger.info "USSD Response: session_id=#{env[:session_id]} duration=#{duration}ms action=#{result[:action]}"
|
|
18
|
+
|
|
19
|
+
result
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module Conduit
|
|
2
|
+
module Middleware
|
|
3
|
+
class SessionTracking < Base
|
|
4
|
+
def call(env)
|
|
5
|
+
session = env[:session]
|
|
6
|
+
|
|
7
|
+
if session && session.navigation_stack.empty?
|
|
8
|
+
track_event("session_started", session)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
result = @app.call(env)
|
|
12
|
+
|
|
13
|
+
if result[:response]&.end?
|
|
14
|
+
track_event("session_ended", session)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
result
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def track_event(event, session)
|
|
23
|
+
return unless session
|
|
24
|
+
|
|
25
|
+
# this could be a call to AppSignal, StatsD ... etc(I'm thinking StatsD)
|
|
26
|
+
Conduit.configuration.logger.info "Track: #{event} for #{session.msisdn}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module Conduit
|
|
2
|
+
module Middleware
|
|
3
|
+
class Throttling < Base
|
|
4
|
+
def initialize(app, max_requests: 10, window: 60)
|
|
5
|
+
super(app)
|
|
6
|
+
@max_requests = max_requests
|
|
7
|
+
@window = window
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(env)
|
|
11
|
+
msisdn = env[:msisdn]
|
|
12
|
+
return @app.call(env) unless msisdn
|
|
13
|
+
|
|
14
|
+
key = "throttle:#{msisdn}"
|
|
15
|
+
count = increment_counter(key)
|
|
16
|
+
|
|
17
|
+
if count > @max_requests
|
|
18
|
+
{
|
|
19
|
+
response: Conduit::Response.new(
|
|
20
|
+
text: "Too many requests. Please try again later.",
|
|
21
|
+
action: :end
|
|
22
|
+
),
|
|
23
|
+
provider: env[:provider]
|
|
24
|
+
}
|
|
25
|
+
else
|
|
26
|
+
@app.call(env)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def increment_counter(key)
|
|
33
|
+
Conduit.configuration.redis_pool.with do |redis|
|
|
34
|
+
count = redis.incr(key)
|
|
35
|
+
redis.expire(key, @window) if count == 1
|
|
36
|
+
count
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module Conduit
|
|
2
|
+
module Middleware
|
|
3
|
+
class Base
|
|
4
|
+
def initialize(app, *args, &block)
|
|
5
|
+
@app = app
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
delegate :call, to: :@app
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
class Chain
|
|
12
|
+
def initialize
|
|
13
|
+
@middlewares = []
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def use(middleware, *args, &block)
|
|
17
|
+
@middlewares << [middleware, args, block]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(env, &final_block)
|
|
21
|
+
chain = final_block || ->(e) { e }
|
|
22
|
+
|
|
23
|
+
@middlewares.reverse_each do |(middleware, args, block)|
|
|
24
|
+
previous_chain = chain
|
|
25
|
+
chain = ->(environment) do
|
|
26
|
+
middleware.new(previous_chain, *args, &block).call(environment)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
chain.call(env)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
delegate :clear, to: :@middlewares
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module Conduit
|
|
2
|
+
module Providers
|
|
3
|
+
class AfricasTalking
|
|
4
|
+
attr_reader :params
|
|
5
|
+
|
|
6
|
+
def initialize(params)
|
|
7
|
+
@params = params
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def parse_request
|
|
11
|
+
{
|
|
12
|
+
session_id: params[:sessionId],
|
|
13
|
+
msisdn: normalize_phone_number(params[:phoneNumber]),
|
|
14
|
+
service_code: params[:serviceCode],
|
|
15
|
+
network_code: params[:networkCode],
|
|
16
|
+
raw_input: params[:text],
|
|
17
|
+
input: extract_latest_input(params[:text])
|
|
18
|
+
}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def format_response(response)
|
|
22
|
+
prefix = response.end? ? "END " : "CON "
|
|
23
|
+
"#{prefix}#{response.text}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def extract_latest_input(text)
|
|
29
|
+
return nil if text.blank?
|
|
30
|
+
text.split("*").last
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# for this one we can use Phobelib.parse(phone).to_s :thinkng?
|
|
34
|
+
def normalize_phone_number(phone)
|
|
35
|
+
phone.to_s.gsub(/\D/, "")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
module Conduit
|
|
2
|
+
class RequestHandler
|
|
3
|
+
def initialize
|
|
4
|
+
@store = SessionStore.new
|
|
5
|
+
@provider = Providers::AfricasTalking
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def process(raw_params)
|
|
9
|
+
# Parse request using AfricasTalking
|
|
10
|
+
provider = @provider.new(raw_params)
|
|
11
|
+
params = provider.parse_request
|
|
12
|
+
|
|
13
|
+
env = {
|
|
14
|
+
params:,
|
|
15
|
+
session_id: params[:session_id],
|
|
16
|
+
msisdn: params[:msisdn],
|
|
17
|
+
service_code: params[:service_code],
|
|
18
|
+
input: params[:input],
|
|
19
|
+
raw_params:,
|
|
20
|
+
provider:
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
# Run through middleware chain
|
|
24
|
+
result = Conduit.configuration.middleware.call(env) do |environment|
|
|
25
|
+
process_request(environment)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Format response
|
|
29
|
+
result[:provider].format_response(result[:response])
|
|
30
|
+
rescue => e
|
|
31
|
+
Conduit.configuration.logger.error "Error processing request: #{e.message}\n#{e.backtrace.join("\n")}"
|
|
32
|
+
error_response = Response.new(text: "Service temporarily unavailable", action: :end)
|
|
33
|
+
provider.format_response(error_response)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def process_request(env)
|
|
39
|
+
params = env[:params]
|
|
40
|
+
|
|
41
|
+
# Get or create session
|
|
42
|
+
session = @store.get(params[:session_id]) || create_session(params)
|
|
43
|
+
env[:session] = session
|
|
44
|
+
|
|
45
|
+
# Check if expired
|
|
46
|
+
if session.expired?
|
|
47
|
+
@store.delete(session.session_id)
|
|
48
|
+
response = Response.new(
|
|
49
|
+
text: "Your session has expired. Please dial again.",
|
|
50
|
+
action: :end
|
|
51
|
+
)
|
|
52
|
+
else
|
|
53
|
+
# Check if there's a pending flow transition from previous request
|
|
54
|
+
if session.data[:pending_flow_transition]
|
|
55
|
+
flow_class_name = session.data[:pending_flow_transition]
|
|
56
|
+
session.data.delete(:pending_flow_transition)
|
|
57
|
+
|
|
58
|
+
# Reset session state for new flow
|
|
59
|
+
session.current_state = nil
|
|
60
|
+
|
|
61
|
+
# Store the current flow for subsequent requests
|
|
62
|
+
session.data[:current_flow] = flow_class_name
|
|
63
|
+
|
|
64
|
+
# Instantiate the new flow and process with empty input (display initial state)
|
|
65
|
+
flow = Object.const_get(flow_class_name).new
|
|
66
|
+
response = flow.process(session, "")
|
|
67
|
+
elsif session.data[:current_flow]
|
|
68
|
+
# Continue with the current flow
|
|
69
|
+
flow = Object.const_get(session.data[:current_flow]).new
|
|
70
|
+
response = flow.process(session, params[:input])
|
|
71
|
+
else
|
|
72
|
+
# Get flow for service code (normal flow - first request)
|
|
73
|
+
flow = Router.find_flow(params[:service_code])
|
|
74
|
+
|
|
75
|
+
# Store the flow class name for subsequent requests
|
|
76
|
+
session.data[:current_flow] = flow.class.name
|
|
77
|
+
|
|
78
|
+
response = flow.process(session, params[:input])
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Handle flow transition if next_flow is specified
|
|
82
|
+
if response.transition?
|
|
83
|
+
# Store the next flow class name for the NEXT request
|
|
84
|
+
session.data[:pending_flow_transition] = response.next_flow.name
|
|
85
|
+
# Keep the response text but change action to continue
|
|
86
|
+
response = Response.new(text: response.text, action: :continue)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Save or cleanup session
|
|
90
|
+
if response.end?
|
|
91
|
+
save_completed_session(session) if Conduit.configuration.save_sessions
|
|
92
|
+
@store.delete(session.session_id)
|
|
93
|
+
else
|
|
94
|
+
@store.set(session)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
{response:, provider: env[:provider], session:}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def create_session(params)
|
|
102
|
+
Session.new(
|
|
103
|
+
session_id: params[:session_id],
|
|
104
|
+
msisdn: params[:msisdn],
|
|
105
|
+
service_code: params[:service_code]
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def save_completed_session(session)
|
|
110
|
+
return unless Conduit.configuration.save_sessions
|
|
111
|
+
|
|
112
|
+
# Save to database if available
|
|
113
|
+
if defined?(Conduit::SessionRecord)
|
|
114
|
+
begin
|
|
115
|
+
record = Conduit::SessionRecord.from_session(session, completed: true)
|
|
116
|
+
record.save!
|
|
117
|
+
Rails.logger.info "Session saved: #{session.session_id}"
|
|
118
|
+
rescue => e
|
|
119
|
+
Rails.logger.error "Failed to save session: #{e.message}"
|
|
120
|
+
end
|
|
121
|
+
else
|
|
122
|
+
Rails.logger.info "Session completed: #{session.to_h}"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Conduit
|
|
2
|
+
class Response
|
|
3
|
+
attr_reader :text, :action, :next_flow
|
|
4
|
+
|
|
5
|
+
def initialize(text:, action: :continue, next_flow: nil)
|
|
6
|
+
@text = text
|
|
7
|
+
@action = action.to_sym
|
|
8
|
+
@next_flow = next_flow
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def continue?
|
|
12
|
+
@action == :continue
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def end?
|
|
16
|
+
@action == :end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def transition?
|
|
20
|
+
!@next_flow.nil?
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module Conduit
|
|
2
|
+
class Router
|
|
3
|
+
class << self
|
|
4
|
+
def routes
|
|
5
|
+
@routes ||= {}
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def draw(&)
|
|
9
|
+
instance_eval(&)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def route(service_code, to:)
|
|
13
|
+
routes[normalize_service_code(service_code)] = to
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def find_flow(service_code)
|
|
17
|
+
flow_class = routes[normalize_service_code(service_code)]
|
|
18
|
+
raise "No flow found for service code: #{service_code}" unless flow_class
|
|
19
|
+
|
|
20
|
+
flow_class.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def normalize_service_code(code)
|
|
26
|
+
code.to_s.gsub(/\D/, "")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module Conduit
|
|
2
|
+
class Session
|
|
3
|
+
attr_accessor :session_id, :msisdn, :service_code,
|
|
4
|
+
:current_state, :navigation_stack, :data,
|
|
5
|
+
:started_at, :last_activity_at
|
|
6
|
+
|
|
7
|
+
def initialize(attrs = {})
|
|
8
|
+
@session_id = attrs[:session_id]
|
|
9
|
+
@msisdn = attrs[:msisdn]
|
|
10
|
+
@service_code = attrs[:service_code]
|
|
11
|
+
@current_state = attrs[:current_state] || "initial"
|
|
12
|
+
@navigation_stack = attrs[:navigation_stack] || []
|
|
13
|
+
@data = attrs[:data] || {}
|
|
14
|
+
@started_at = attrs[:started_at] || Time.current
|
|
15
|
+
@last_activity_at = attrs[:last_activity_at] || Time.current
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def navigate_to(state)
|
|
19
|
+
@navigation_stack.push(@current_state) unless @current_state == "initial"
|
|
20
|
+
@navigation_stack = @navigation_stack.last(Conduit.configuration.max_navigation_depth)
|
|
21
|
+
@current_state = state.to_s
|
|
22
|
+
@last_activity_at = Time.current
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def go_back
|
|
26
|
+
previous_state = @navigation_stack.pop
|
|
27
|
+
@current_state = previous_state if previous_state
|
|
28
|
+
@last_activity_at = Time.current
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def can_go_back?
|
|
32
|
+
@navigation_stack.any?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def duration
|
|
36
|
+
Time.current - @started_at
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def expired?
|
|
40
|
+
Time.current - @last_activity_at > Conduit.configuration.session_ttl
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def to_h
|
|
44
|
+
{
|
|
45
|
+
session_id:,
|
|
46
|
+
msisdn:,
|
|
47
|
+
service_code:,
|
|
48
|
+
current_state:,
|
|
49
|
+
navigation_stack:,
|
|
50
|
+
data:,
|
|
51
|
+
started_at: started_at.to_i,
|
|
52
|
+
last_activity_at: last_activity_at.to_i
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
delegate :to_json, to: :to_h
|
|
57
|
+
|
|
58
|
+
def self.from_hash(hash)
|
|
59
|
+
hash = hash.with_indifferent_access
|
|
60
|
+
|
|
61
|
+
new(
|
|
62
|
+
session_id: hash[:session_id],
|
|
63
|
+
msisdn: hash[:msisdn],
|
|
64
|
+
service_code: hash[:service_code],
|
|
65
|
+
current_state: hash[:current_state],
|
|
66
|
+
navigation_stack: hash[:navigation_stack] || [],
|
|
67
|
+
data: hash[:data] || {},
|
|
68
|
+
started_at: Time.at((hash[:started_at] || Time.current).to_i),
|
|
69
|
+
last_activity_at: Time.at((hash[:last_activity_at] || Time.current).to_i)
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module Conduit
|
|
2
|
+
class SessionStore
|
|
3
|
+
def initialize
|
|
4
|
+
@pool = Conduit.configuration.redis_pool
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def get(session_id)
|
|
8
|
+
@pool.with do |redis|
|
|
9
|
+
data = redis.get(key_for(session_id))
|
|
10
|
+
return nil unless data
|
|
11
|
+
|
|
12
|
+
Session.from_hash(JSON.parse(data))
|
|
13
|
+
end
|
|
14
|
+
rescue => e
|
|
15
|
+
Conduit.configuration.logger.error "Failed to get session #{session_id}: #{e.message}"
|
|
16
|
+
nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def set(session)
|
|
20
|
+
@pool.with do |redis|
|
|
21
|
+
redis.setex(
|
|
22
|
+
key_for(session.session_id),
|
|
23
|
+
Conduit.configuration.session_ttl.to_i,
|
|
24
|
+
session.to_json
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def delete(session_id)
|
|
30
|
+
@pool.with do |redis|
|
|
31
|
+
redis.del(key_for(session_id))
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def key_for(session_id)
|
|
38
|
+
"conduit:session:#{session_id}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
module Conduit
|
|
2
|
+
# This behaves like single screen/menu in a USSD flow. Think of it as a "page" in your application.
|
|
3
|
+
#
|
|
4
|
+
class State
|
|
5
|
+
attr_reader :name, :options
|
|
6
|
+
|
|
7
|
+
def initialize(name, options = {}, &)
|
|
8
|
+
@name = name.to_sym
|
|
9
|
+
@options = options
|
|
10
|
+
@transitions = {}
|
|
11
|
+
@display_block = nil
|
|
12
|
+
@before_callbacks = []
|
|
13
|
+
@validations = []
|
|
14
|
+
@on_valid_block = nil
|
|
15
|
+
@on_invalid_block = nil
|
|
16
|
+
|
|
17
|
+
instance_eval(&) if block_given?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def display(text = nil, &block)
|
|
21
|
+
@display_block = if block
|
|
22
|
+
lambda do |session|
|
|
23
|
+
# Try to execute block in DisplayBuilder context
|
|
24
|
+
builder = DisplayBuilder.new
|
|
25
|
+
result = builder.instance_exec(session, &block)
|
|
26
|
+
|
|
27
|
+
# If block returns a string, use that (old style)
|
|
28
|
+
# Otherwise, use the builder's accumulated content (new DSL style)
|
|
29
|
+
if result.is_a?(String)
|
|
30
|
+
result
|
|
31
|
+
else
|
|
32
|
+
builder.to_s
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
else
|
|
36
|
+
->(_) { text }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def validates(validator_name = nil, *args, **options, &custom_validator)
|
|
41
|
+
if custom_validator
|
|
42
|
+
# Custom validator block
|
|
43
|
+
@validations << custom_validator
|
|
44
|
+
elsif validator_name.is_a?(Symbol)
|
|
45
|
+
# Built-in validator
|
|
46
|
+
validator = Validator.public_send(validator_name, *args, **options)
|
|
47
|
+
@validations << validator
|
|
48
|
+
elsif validator_name.nil?
|
|
49
|
+
raise ArgumentError, "Must provide either a validator name or a block"
|
|
50
|
+
else
|
|
51
|
+
raise ArgumentError, "Invalid validator: #{validator_name}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def on_valid(&block)
|
|
56
|
+
@on_valid_block = block
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def on_invalid(&block)
|
|
60
|
+
@on_invalid_block = block
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def on(input, options = {}, &block)
|
|
64
|
+
@transitions[input.to_s] = Transition.new(input, options, block)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def on_any(options = {}, &block)
|
|
68
|
+
@transitions[:any] = Transition.new(:any, options, block)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def before_render(&block)
|
|
72
|
+
@before_callbacks << block
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Runtime Methods
|
|
76
|
+
def render(session)
|
|
77
|
+
run_callbacks(@before_callbacks, session)
|
|
78
|
+
|
|
79
|
+
content = if @display_block
|
|
80
|
+
@display_block.call(session)
|
|
81
|
+
else
|
|
82
|
+
"No content defined for state: #{@name}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
Response.new(text: content, action: :continue)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def handle_input(input, session, flow = nil)
|
|
89
|
+
# Check for exact transition match first (before validations)
|
|
90
|
+
# This allows specific handlers like `on "0"` to override validations
|
|
91
|
+
exact_transition = @transitions[input]
|
|
92
|
+
|
|
93
|
+
if exact_transition
|
|
94
|
+
return exact_transition.execute(input, session)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Check for global back navigation (only if no exact match)
|
|
98
|
+
if input == "0" && session.can_go_back?
|
|
99
|
+
session.go_back
|
|
100
|
+
return nil
|
|
101
|
+
elsif input == "00" && flow
|
|
102
|
+
session.navigate_to(flow.class.initial_state_name)
|
|
103
|
+
return nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Run validations if any are defined
|
|
107
|
+
if @validations.any?
|
|
108
|
+
validation_error = run_validations(input, session, flow)
|
|
109
|
+
|
|
110
|
+
if validation_error
|
|
111
|
+
# Validation failed
|
|
112
|
+
if @on_invalid_block
|
|
113
|
+
return @on_invalid_block.call(validation_error, session)
|
|
114
|
+
else
|
|
115
|
+
return Response.new(text: validation_error, action: :continue)
|
|
116
|
+
end
|
|
117
|
+
elsif @on_valid_block
|
|
118
|
+
# Validation passed
|
|
119
|
+
result = @on_valid_block.call(input, session)
|
|
120
|
+
return result if result.is_a?(Response)
|
|
121
|
+
|
|
122
|
+
# Continue with normal transition handling
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Check for catch-all transition
|
|
127
|
+
transition = @transitions[:any]
|
|
128
|
+
|
|
129
|
+
return nil unless transition
|
|
130
|
+
|
|
131
|
+
transition.execute(input, session)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def run_callbacks(callbacks, *)
|
|
137
|
+
callbacks.each { |cb| cb.call(*) }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def run_validations(input, session, flow)
|
|
141
|
+
@validations.each do |validation|
|
|
142
|
+
result = if validation.respond_to?(:call)
|
|
143
|
+
# Lambda or proc
|
|
144
|
+
validation.call(input, session)
|
|
145
|
+
else
|
|
146
|
+
# Method name (symbol)
|
|
147
|
+
flow.send(validation, input, session)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# If validation returns a string, it's an error message
|
|
151
|
+
return result if result.is_a?(String)
|
|
152
|
+
|
|
153
|
+
# If validation returns false, use a generic message
|
|
154
|
+
return "Invalid input" if result == false
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
nil # No errors
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# This defines how users move between states based on their input.
|
|
162
|
+
#
|
|
163
|
+
class Transition
|
|
164
|
+
def initialize(matcher, options = {}, block = nil)
|
|
165
|
+
@matcher = matcher
|
|
166
|
+
@to = options[:to]
|
|
167
|
+
@block = block
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def execute(input, session)
|
|
171
|
+
# Execute block if provided
|
|
172
|
+
if @block
|
|
173
|
+
result = @block.call(input, session)
|
|
174
|
+
return result if result.is_a?(Response)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Handle navigation
|
|
178
|
+
if @to
|
|
179
|
+
if @to == :back
|
|
180
|
+
session.go_back
|
|
181
|
+
else
|
|
182
|
+
session.navigate_to(@to)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
nil
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|