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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +426 -0
  4. data/Rakefile +2 -0
  5. data/app/assets/stylesheets/conduit/application.css +15 -0
  6. data/app/controllers/conduit/application_controller.rb +4 -0
  7. data/app/helpers/conduit/application_helper.rb +4 -0
  8. data/app/jobs/conduit/application_job.rb +4 -0
  9. data/app/jobs/conduit/save_session_job.rb +11 -0
  10. data/app/models/conduit/application_record.rb +5 -0
  11. data/app/models/conduit/session_record.rb +28 -0
  12. data/config/routes.rb +2 -0
  13. data/lib/conduit/configuration.rb +25 -0
  14. data/lib/conduit/display_builder.rb +58 -0
  15. data/lib/conduit/engine.rb +21 -0
  16. data/lib/conduit/flow.rb +54 -0
  17. data/lib/conduit/middleware/logging.rb +23 -0
  18. data/lib/conduit/middleware/session_tracking.rb +30 -0
  19. data/lib/conduit/middleware/throttling.rb +41 -0
  20. data/lib/conduit/middleware.rb +36 -0
  21. data/lib/conduit/providers/africas_talking.rb +39 -0
  22. data/lib/conduit/request_handler.rb +126 -0
  23. data/lib/conduit/response.rb +23 -0
  24. data/lib/conduit/router.rb +30 -0
  25. data/lib/conduit/session.rb +73 -0
  26. data/lib/conduit/session_store.rb +41 -0
  27. data/lib/conduit/state.rb +189 -0
  28. data/lib/conduit/validator.rb +55 -0
  29. data/lib/conduit/version.rb +3 -0
  30. data/lib/conduit.rb +31 -0
  31. data/lib/generators/conduit/install/install_generator.rb +41 -0
  32. data/lib/generators/conduit/install/templates/conduit.rb +26 -0
  33. data/lib/generators/conduit/install/templates/example_flow.rb +72 -0
  34. data/lib/generators/conduit/install/templates/ussd_controller.rb +11 -0
  35. data/lib/generators/conduit/migration/migration_generator.rb +21 -0
  36. data/lib/generators/conduit/migration/templates/create_conduit_sessions.rb +19 -0
  37. data/lib/tasks/conduit_tasks.rake +4 -0
  38. 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