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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 557f1741896d47caed701bf3bb0c73f19dc128dd0cdf5cd076699baebaf8435b
4
+ data.tar.gz: dd048e06d6d0cac802f7ad3be66ad62ad0fa20730838bbc2c317281da7a5eb91
5
+ SHA512:
6
+ metadata.gz: fa64e78847e2ec50787f91d8bcc27a3cca8c81d2cac82b8dcd361b9bfebc556092a20d67898676f4b2bc35cc78bc4aa4f4c1927f329df8af20ac0a8622669867
7
+ data.tar.gz: 2c1ea4bffd6af908fbf71581a0f5100c56ecc372a48cb5516e783af31b0f195eb8d585072184b8488abd1132abe69eea8e7ed69fe61be79998c467fd9595f086
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright charles chuck
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,426 @@
1
+ # Conduit
2
+
3
+ Lightning-fast USSD flow engine for Rails applications. Build interactive USSD services with an expressive DSL, Redis-backed session management, and built-in support for AfricasTalking.
4
+
5
+ ## Design Philosophy
6
+
7
+ Conduit is built on four core principles that make USSD development in Africa a joy:
8
+
9
+ ### 1. **Speed is Everything**
10
+ USSD sessions have a strict 60-120 second timeout. Every millisecond counts. Conduit uses Redis connection pooling, minimal middleware overhead, and optimized session serialization to deliver sub-50ms response times.
11
+
12
+ ### 2. **Developer Experience First**
13
+ Your flow code should read like a specification. Conduit's DSL is designed to be self-documenting, making it easy for teams to understand, maintain, and extend USSD applications.
14
+
15
+ ```ruby
16
+ state :welcome do
17
+ display "Welcome! What would you like to do?"
18
+ on "1", to: :check_balance
19
+ on "2", to: :send_money
20
+ end
21
+ ```
22
+
23
+ ### 3. **African Mobile Networks are Unique**
24
+ Built specifically for African telecom infrastructure. Handles network delays, session drops, and the quirks of different USSD gateways. First-class support for AfricasTalking with extensible provider architecture.
25
+
26
+ ### 4. **Production-Grade from Day One**
27
+ Includes middleware for logging, throttling, and session tracking. Built-in error handling, session persistence, and monitoring hooks. Deploy with confidence.
28
+
29
+ ## Features
30
+
31
+ - **Blazing Fast** - Redis-backed sessions with sub-50ms response times
32
+ - **Expressive DSL** - Write flows that read like specifications
33
+ - **AfricasTalking Ready** - Built-in provider support with extensible architecture
34
+ - **Smart Navigation** - Automatic back (0) and home (00) handling with navigation stack
35
+ - **Production Ready** - Middleware, throttling, error handling, and session persistence
36
+ - **Multi-language** - Easy internationalization support
37
+ - **60-120s Sessions** - Optimized for USSD's time constraints
38
+ - **Rails Integration** - Seamless Rails engine with generators and conventions
39
+
40
+ ## Quick Start
41
+
42
+ ### Installation
43
+
44
+ Add to your Gemfile:
45
+
46
+ ```ruby
47
+ gem "conduit-ussd"
48
+ ```
49
+
50
+ Then run:
51
+
52
+ ```bash
53
+ bundle install
54
+ rails generate conduit:install
55
+ rails generate conduit:migration
56
+ rails db:migrate
57
+ ```
58
+
59
+ ### Your First Flow
60
+
61
+ ```ruby
62
+ # app/flows/banking_flow.rb
63
+ class BankingFlow < Conduit::Flow
64
+ initial_state :welcome
65
+
66
+ state :welcome do
67
+ display <<~TEXT
68
+ Welcome to MobileBank
69
+
70
+ 1. Check Balance
71
+ 2. Send Money
72
+ 3. Buy Airtime
73
+ TEXT
74
+
75
+ on "1", to: :check_balance
76
+ on "2", to: :send_money
77
+ on "3", to: :buy_airtime
78
+ end
79
+
80
+ state :check_balance do
81
+ display do |session|
82
+ balance = User.find_by(phone: session.msisdn)&.balance || 0
83
+ "Your balance is KES #{balance}"
84
+ end
85
+ end
86
+
87
+ state :send_money do
88
+ display "Enter phone number:"
89
+
90
+ on_any do |input, session|
91
+ if valid_phone?(input)
92
+ session.data[:recipient] = input
93
+ session.navigate_to(:enter_amount)
94
+ else
95
+ Conduit::Response.new(text: "Invalid phone number. Try again:")
96
+ end
97
+ end
98
+ end
99
+
100
+ state :enter_amount do
101
+ display do |session|
102
+ "Send money to #{session.data[:recipient]}\nEnter amount:"
103
+ end
104
+
105
+ on_any do |input, session|
106
+ amount = input.to_i
107
+ if amount > 0 && amount <= user_balance(session)
108
+ process_transfer(session, amount)
109
+ Conduit::Response.new(text: "Money sent successfully!", action: :end)
110
+ else
111
+ Conduit::Response.new(text: "Invalid amount. Try again:")
112
+ end
113
+ end
114
+ end
115
+
116
+ private
117
+
118
+ def valid_phone?(phone)
119
+ phone.match?(/^254\d{9}$/)
120
+ end
121
+
122
+ def user_balance(session)
123
+ User.find_by(phone: session.msisdn)&.balance || 0
124
+ end
125
+
126
+ def process_transfer(session, amount)
127
+ # Your transfer logic here
128
+ TransferService.new(
129
+ from: session.msisdn,
130
+ to: session.data[:recipient],
131
+ amount: amount
132
+ ).call
133
+ end
134
+ end
135
+ ```
136
+
137
+ ### Configuration
138
+
139
+ ```ruby
140
+ # config/initializers/conduit.rb
141
+ Conduit.configure do |config|
142
+ config.redis_url = ENV.fetch("REDIS_URL", "redis://localhost:6379/1")
143
+ config.session_ttl = 90.seconds
144
+ config.max_navigation_depth = 10
145
+
146
+ # Middleware (order matters)
147
+ config.middleware.use Conduit::Middleware::Logging
148
+ config.middleware.use Conduit::Middleware::Throttling, max_requests: 20, window: 60
149
+ config.middleware.use Conduit::Middleware::SessionTracking
150
+ end
151
+
152
+ # Route service codes to flows
153
+ Conduit::Router.draw do
154
+ route "*123#", to: BankingFlow
155
+ route "*456#", to: AirtimeFlow
156
+ route "*789#", to: UtilitiesFlow
157
+ end
158
+ ```
159
+
160
+ ### Controller
161
+
162
+ ```ruby
163
+ # app/controllers/ussd_controller.rb
164
+ class UssdController < ApplicationController
165
+ def handle
166
+ handler = Conduit::RequestHandler.new(
167
+ Conduit::Providers::AfricasTalking.new(params)
168
+ )
169
+
170
+ response = handler.process
171
+ render plain: response
172
+ end
173
+ end
174
+ ```
175
+
176
+ ## Advanced Features
177
+
178
+ ### Session Management
179
+
180
+ ```ruby
181
+ state :collect_info do
182
+ display "Enter your name:"
183
+
184
+ on_any do |input, session|
185
+ session.data[:user_name] = input
186
+ session.data[:collected_at] = Time.current
187
+ session.navigate_to(:confirm)
188
+ end
189
+ end
190
+
191
+ state :confirm do
192
+ display do |session|
193
+ "Hello #{session.data[:user_name]}!
194
+ Session started: #{session.started_at}
195
+ Duration: #{session.duration.to_i}s"
196
+ end
197
+ end
198
+ ```
199
+
200
+ ### Navigation Stack
201
+
202
+ ```ruby
203
+ # Automatic back/home handling
204
+ state :menu do
205
+ display <<~TEXT
206
+ Main Menu
207
+ 1. Services
208
+ 2. Account
209
+
210
+ 0. Back
211
+ 00. Home
212
+ TEXT
213
+
214
+ # 0 and 00 are handled automatically
215
+ on "1", to: :services
216
+ on "2", to: :account
217
+ end
218
+ ```
219
+
220
+ ### Conditional Flows
221
+
222
+ ```ruby
223
+ state :check_eligibility do
224
+ display "Checking your eligibility..."
225
+
226
+ transition do |session|
227
+ user = User.find_by(phone: session.msisdn)
228
+
229
+ if user&.kyc_verified?
230
+ :premium_services
231
+ elsif user&.basic_verified?
232
+ :basic_services
233
+ else
234
+ :verification_required
235
+ end
236
+ end
237
+ end
238
+ ```
239
+
240
+ ### Error Handling
241
+
242
+ ```ruby
243
+ state :risky_operation do
244
+ display "Processing..."
245
+
246
+ on_any do |input, session|
247
+ begin
248
+ result = ExternalService.call(input)
249
+ session.navigate_to(:success)
250
+ rescue ExternalService::Error => e
251
+ Conduit.logger.error("Service failed: #{e.message}")
252
+ Conduit::Response.new(text: "Service temporarily unavailable. Try again later.", action: :end)
253
+ end
254
+ end
255
+ end
256
+ ```
257
+
258
+ ### Custom Middleware
259
+
260
+ ```ruby
261
+ class AuthenticationMiddleware < Conduit::Middleware::Base
262
+ def call(env)
263
+ session = env[:session]
264
+
265
+ unless authenticated?(session.msisdn)
266
+ return Conduit::Response.end("Please register first by dialing *100#")
267
+ end
268
+
269
+ @app.call(env)
270
+ end
271
+
272
+ private
273
+
274
+ def authenticated?(phone)
275
+ User.exists?(phone: phone, status: 'active')
276
+ end
277
+ end
278
+
279
+ # Add to config
280
+ config.middleware.use AuthenticationMiddleware
281
+ ```
282
+
283
+ ## Testing
284
+
285
+ ```ruby
286
+ # spec/flows/banking_flow_spec.rb
287
+ RSpec.describe BankingFlow do
288
+ let(:session) { build_session(msisdn: "254712345678") }
289
+ let(:flow) { described_class.new }
290
+
291
+ describe "welcome state" do
292
+ it "displays menu options" do
293
+ response = flow.process(session)
294
+
295
+ expect(response.text).to include("Welcome to MobileBank")
296
+ expect(response.text).to include("1. Check Balance")
297
+ expect(response).to be_continue
298
+ end
299
+ end
300
+
301
+ describe "balance inquiry" do
302
+ before { session.current_state = :welcome }
303
+
304
+ it "shows balance when option 1 is selected" do
305
+ create(:user, phone: "254712345678", balance: 1500)
306
+
307
+ response = flow.process(session, "1")
308
+
309
+ expect(response.text).to include("Your balance is KES 1500")
310
+ expect(response).to be_end
311
+ end
312
+ end
313
+ end
314
+ ```
315
+
316
+ ## Deployment
317
+
318
+ ### Production Configuration
319
+
320
+ ```ruby
321
+ # config/environments/production.rb
322
+ config.cache_store = :redis_cache_store, {
323
+ url: ENV['REDIS_URL'],
324
+ pool_size: 20,
325
+ pool_timeout: 0.1
326
+ }
327
+
328
+ # Use separate Redis instance for Conduit sessions
329
+ Conduit.configure do |config|
330
+ config.redis_url = ENV.fetch("CONDUIT_REDIS_URL", ENV["REDIS_URL"])
331
+ config.redis_pool_size = 20
332
+ config.session_ttl = 120.seconds
333
+ end
334
+ ```
335
+
336
+ ### Monitoring
337
+
338
+ ```ruby
339
+ # config/initializers/conduit.rb
340
+ class MetricsMiddleware < Conduit::Middleware::Base
341
+ def call(env)
342
+ start_time = Time.current
343
+
344
+ result = @app.call(env)
345
+
346
+ duration = (Time.current - start_time) * 1000
347
+ Rails.logger.info("USSD Response time: #{duration.round(2)}ms")
348
+
349
+ # Send to your monitoring service
350
+ StatsD.histogram('ussd.response_time', duration)
351
+
352
+ result
353
+ end
354
+ end
355
+
356
+ config.middleware.use MetricsMiddleware
357
+ ```
358
+
359
+ ## Provider Support
360
+
361
+ Currently supports AfricasTalking with extensible provider architecture:
362
+
363
+ ```ruby
364
+ # Custom provider
365
+ class CustomProvider
366
+ def initialize(params)
367
+ @params = params
368
+ end
369
+
370
+ def parse_request
371
+ {
372
+ session_id: @params[:session_id],
373
+ msisdn: @params[:phone_number],
374
+ service_code: @params[:service_code],
375
+ input: @params[:user_input]
376
+ }
377
+ end
378
+
379
+ def format_response(response)
380
+ {
381
+ message: response.text,
382
+ action: response.end? ? 'terminate' : 'continue'
383
+ }.to_json
384
+ end
385
+ end
386
+ ```
387
+
388
+ ## Architecture
389
+
390
+ Conduit follows a middleware-based architecture:
391
+
392
+ ```
393
+ Request → Provider → Middleware Chain → Router → Flow → Response → Provider → Response
394
+ ```
395
+
396
+ - **Provider**: Parses telecom-specific requests
397
+ - **Middleware**: Cross-cutting concerns (logging, throttling, auth)
398
+ - **Router**: Maps service codes to flows
399
+ - **Flow**: Your business logic
400
+ - **Session**: Redis-backed state management
401
+
402
+ ## Contributing
403
+
404
+ 1. Fork the repository
405
+ 2. Create your feature branch (`git checkout -b feature/amazing-feature`)
406
+ 3. Write tests for your changes
407
+ 4. Ensure all tests pass (`bundle exec rspec`)
408
+ 5. Run the linter (`bundle exec rubocop`)
409
+ 6. Commit your changes (`git commit -m 'Add amazing feature'`)
410
+ 7. Push to the branch (`git push origin feature/amazing-feature`)
411
+ 8. Open a Pull Request
412
+
413
+ ## License
414
+
415
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
416
+
417
+ ## Support
418
+
419
+ - 📧 Email: support@conduit-ussd.com
420
+ - 🐛 Issues: [GitHub Issues](https://github.com/chalchuck/conduit/issues)
421
+ - 📖 Docs: [Full Documentation](https://docs.conduit-ussd.com)
422
+ - 💬 Community: [Discord](https://discord.gg/conduit-ussd)
423
+
424
+ ---
425
+
426
+ Built with ❤️ for African developers by African developers.
data/Rakefile ADDED
@@ -0,0 +1,2 @@
1
+ require "bundler/setup"
2
+ require "bundler/gem_tasks"
@@ -0,0 +1,15 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
@@ -0,0 +1,4 @@
1
+ module Conduit
2
+ class ApplicationController < ActionController::Base
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Conduit
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Conduit
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,11 @@
1
+ module Conduit
2
+ class SaveSessionJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ def perform(session_data)
6
+ session = Session.from_hash(session_data)
7
+ record = SessionRecord.from_session(session, completed: true)
8
+ record.save!
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,5 @@
1
+ module Conduit
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,28 @@
1
+ module Conduit
2
+ class SessionRecord < ApplicationRecord
3
+ self.table_name = "conduit_sessions"
4
+
5
+ validates :session_id, presence: true, uniqueness: true
6
+ validates :msisdn, presence: true
7
+ validates :started_at, presence: true
8
+
9
+ scope :completed, -> { where(completed: true) }
10
+ scope :incomplete, -> { where(completed: false) }
11
+ scope :recent, -> { order(created_at: :desc) }
12
+ scope :for_phone, ->(msisdn) { where(msisdn:) }
13
+
14
+ def self.from_session(session, completed: false)
15
+ new(
16
+ session_id: session.session_id,
17
+ msisdn: session.msisdn,
18
+ service_code: session.service_code,
19
+ final_state: session.current_state,
20
+ data: session.data,
21
+ duration_seconds: session.duration.to_i,
22
+ completed:,
23
+ started_at: session.started_at,
24
+ completed_at: completed ? Time.current : nil
25
+ )
26
+ end
27
+ end
28
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,2 @@
1
+ Conduit::Engine.routes.draw do
2
+ end
@@ -0,0 +1,25 @@
1
+ # lib/conduit/configuration.rb
2
+ module Conduit
3
+ class Configuration
4
+ attr_accessor :session_ttl, :redis_url, :max_navigation_depth,
5
+ :redis_pool_size, :logger, :save_sessions
6
+
7
+ def initialize
8
+ @session_ttl = 90.seconds
9
+ @redis_url = "redis://localhost:6379/1"
10
+ @max_navigation_depth = 10
11
+ @redis_pool_size = 10 # this number is experimental+configurable
12
+ @logger = Rails.logger
13
+ @save_sessions = true
14
+ @middleware = ::Conduit::Middleware::Chain.new
15
+ end
16
+
17
+ attr_reader :middleware
18
+
19
+ def redis_pool
20
+ @redis_pool ||= ConnectionPool.new(size: redis_pool_size, timeout: 0.1) do
21
+ Redis.new(url: redis_url, driver: :hiredis)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,58 @@
1
+ module Conduit
2
+ class DisplayBuilder
3
+ attr_reader :parts
4
+
5
+ def initialize
6
+ @parts = []
7
+ end
8
+
9
+ def header(*lines)
10
+ @parts << lines.join("\n")
11
+ @parts << "" # Add blank line after header
12
+ end
13
+
14
+ def text(content)
15
+ @parts << content
16
+ end
17
+
18
+ def menu(&block)
19
+ menu_builder = MenuBuilder.new
20
+ menu_builder.instance_eval(&block)
21
+ @parts << menu_builder.to_s
22
+ end
23
+
24
+ def blank_line
25
+ @parts << ""
26
+ end
27
+
28
+ def to_s
29
+ @parts.join("\n")
30
+ end
31
+ end
32
+
33
+ class MenuBuilder
34
+ def initialize
35
+ @options = []
36
+ end
37
+
38
+ def option(number, text)
39
+ @options << "#{number}. #{text}"
40
+ end
41
+
42
+ def back_option(text = "Back")
43
+ @options << "0. #{text}"
44
+ end
45
+
46
+ def home_option(text = "Main Menu")
47
+ @options << "00. #{text}"
48
+ end
49
+
50
+ def exit_option(text = "Exit")
51
+ @options << "000. #{text}"
52
+ end
53
+
54
+ def to_s
55
+ @options.join("\n")
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,21 @@
1
+ require "redis"
2
+ require "connection_pool"
3
+
4
+ module Conduit
5
+ class Engine < ::Rails::Engine
6
+ isolate_namespace Conduit
7
+
8
+ config.generators do |generator|
9
+ generator.test_framework :rspec
10
+ end
11
+
12
+ config.autoload_paths << File.expand_path("../", __dir__)
13
+
14
+ initializer "conduit.setup" do
15
+ Conduit.configure do |config|
16
+ config.session_ttl ||= 90.seconds
17
+ config.redis_url ||= ENV.fetch("REDIS_URL", "redis://localhost:6379/1")
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,54 @@
1
+ module Conduit
2
+ class Flow
3
+ class InvalidTransition < StandardError; end
4
+
5
+ class << self
6
+ attr_reader :initial_state_name
7
+
8
+ def states
9
+ @states ||= {}
10
+ end
11
+
12
+ def initial_state(name)
13
+ @initial_state_name = name
14
+ end
15
+
16
+ def state(name, options = {}, &)
17
+ states[name.to_sym] = State.new(name, options, &)
18
+ end
19
+ end
20
+
21
+ def initialize
22
+ @states = self.class.states
23
+ @initial_state = self.class.initial_state_name
24
+ end
25
+
26
+ def process(session, input = nil)
27
+ # Initialize state if needed
28
+ if session.current_state.nil? || session.current_state == "initial"
29
+ session.current_state = @initial_state
30
+ end
31
+
32
+ # Get current state object
33
+ current_state = @states[session.current_state.to_sym]
34
+
35
+ unless current_state
36
+ raise InvalidTransition, "State '#{session.current_state}' not found"
37
+ end
38
+
39
+ # Process input if provided
40
+ if input.present?
41
+ result = current_state.handle_input(input, session, self)
42
+ return result if result.is_a?(Response)
43
+
44
+ # If state changed, render new state
45
+ if session.current_state != current_state.name
46
+ current_state = @states[session.current_state.to_sym]
47
+ end
48
+ end
49
+
50
+ # Render current state
51
+ current_state.render(session)
52
+ end
53
+ end
54
+ end