flow_chat 0.2.1 โ†’ 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6c1e5cd739c50f112b6a5637e712756e0b96bdb18c2276c7d30db2b2a071cc23
4
- data.tar.gz: f701ad5050599c06b87eba80841823b04eef156e7ea3e77527a0be91519b0d48
3
+ metadata.gz: 31c7630d410bbcff17307ade16634f5d5c9662f91eeeab8365b98681886de6b3
4
+ data.tar.gz: 4a7d4059a7a60dee5af2b189fa33c4638a439af903a72f38ffa5a67f9325115d
5
5
  SHA512:
6
- metadata.gz: ef7e1721b7c5453130999b2ef899af4f20ba6db81ac4b3956ce7ade5956bf2b61e248b2be66ace885c633d4d612025208b31d1ff081ef1a0d671f579e0d3db38
7
- data.tar.gz: 2581189b102229a0e8843691f38eaf91a9c7bf1d2db7c7a71e685124a849621cf55bdd33c6cdd1c928e2bfac051bca597601875dc9cff921063a98ff954a7b82
6
+ metadata.gz: 6040ccd725fa1a24b7ff46b5d288e11a1f1a4a2d58b4e773a843153a7d2cf3fb7c1fd7971dd27ed2bc938bf12da17a42b9bdbee9db06152507051eca57592fdb
7
+ data.tar.gz: 002d2fc1f5b6ea0b20e4229220801bc94311b5237ce24891a571f6f544abaf072a6fdf71de15632069a40d1b29b58e349f937b80ff3975f97bd3a607053b2c39
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.2.2
data/Gemfile CHANGED
@@ -4,4 +4,6 @@ source "https://rubygems.org"
4
4
  gemspec
5
5
 
6
6
  gem "rake", "~> 12.0"
7
- gem "rspec", "~> 3.0"
7
+ gem "minitest", "~> 5.0"
8
+ gem "minitest-reporters", "~> 1.4"
9
+ gem "ostruct"
data/README.md CHANGED
@@ -1,85 +1,473 @@
1
1
  # FlowChat
2
2
 
3
- FlowChat is a Rails framework designed for crafting Menu-based conversation workflows, such as those used in USSD systems. It introduces an intuitive approach to defining conversation flows in Ruby, facilitating clear and logical flow development. Currently supporting USSD with plans to extend functionality to WhatsApp and Telegram, FlowChat makes multi-channel user interaction seamless and efficient.
3
+ FlowChat is a Rails framework designed for building sophisticated conversational workflows, particularly for USSD (Unstructured Supplementary Service Data) systems. It provides an intuitive Ruby DSL for creating multi-step, menu-driven conversations with automatic session management, input validation, and flow control.
4
4
 
5
- The framework's architecture leverages a middleware processing pipeline, offering flexibility in customizing the conversation handling process.
5
+ **Key Features:**
6
+ - ๐ŸŽฏ **Declarative Flow Definition** - Define conversation flows as Ruby classes
7
+ - ๐Ÿ”„ **Automatic Session Management** - Persistent state across requests
8
+ - โœ… **Input Validation & Transformation** - Built-in validation and data conversion
9
+ - ๐ŸŒŠ **Middleware Architecture** - Flexible request processing pipeline
10
+ - ๐Ÿ“ฑ **USSD Gateway Support** - Currently supports Nalo and Nsano gateways
11
+ - ๐Ÿงช **Built-in Testing Tools** - USSD simulator for local development
6
12
 
7
- ## Getting Started
13
+ ## Architecture Overview
8
14
 
9
- ### Installation
15
+ FlowChat uses a **request-per-interaction** model where each user input creates a new request. The framework maintains conversation state through session storage while processing each interaction through a middleware pipeline.
10
16
 
11
- Incorporate FlowChat into your Rails project by adding the following line to your Gemfile:
17
+ ```
18
+ User Input โ†’ Gateway โ†’ Session โ†’ Pagination โ†’ Custom โ†’ Executor โ†’ Flow โ†’ Response
19
+ โ†“
20
+ Session Storage
21
+ ```
22
+
23
+ **Middleware Pipeline:**
24
+ - **Gateway**: USSD provider communication (Nalo/Nsano)
25
+ - **Session**: Load/save conversation state
26
+ - **Pagination**: Split long responses into pages
27
+ - **Custom**: Your application middleware (logging, auth, etc.)
28
+ - **Executor**: Execute flow methods and handle interrupts
29
+
30
+ ## Installation
31
+
32
+ Add FlowChat to your Rails application's Gemfile:
12
33
 
13
34
  ```ruby
14
- gem 'flow_chat', '~> 0.2.0'
35
+ gem 'flow_chat'
15
36
  ```
16
37
 
17
- Then, execute:
38
+ Then execute:
18
39
 
19
40
  ```bash
20
41
  bundle install
21
42
  ```
22
43
 
23
- Alternatively, you can install it directly using:
44
+ ## Quick Start
24
45
 
25
- ```bash
26
- gem install flow_chat
46
+ ### 1. Create Your First Flow
47
+
48
+ Create a flow class in `app/flow_chat/welcome_flow.rb`:
49
+
50
+ ```ruby
51
+ class WelcomeFlow < FlowChat::Flow
52
+ def main_page
53
+ name = app.screen(:name) do |prompt|
54
+ prompt.ask "Welcome! What's your name?",
55
+ transform: ->(input) { input.strip.titleize }
56
+ end
57
+
58
+ app.say "Hello, #{name}! Welcome to FlowChat."
59
+ end
60
+ end
27
61
  ```
28
62
 
29
- ### Basic Usage
63
+ ### 2. Set Up the Controller
64
+
65
+ Create a controller to handle USSD requests:
66
+
67
+ ```ruby
68
+ class UssdController < ApplicationController
69
+ skip_forgery_protection
70
+
71
+ def process_request
72
+ processor = FlowChat::Ussd::Processor.new(self) do |config|
73
+ config.use_gateway FlowChat::Ussd::Gateway::Nalo
74
+ config.use_session_store FlowChat::Session::RailsSessionStore
75
+ end
30
76
 
31
- #### Building Your First Flow
77
+ processor.run WelcomeFlow, :main_page
78
+ end
79
+ end
80
+ ```
32
81
 
33
- Create a new class derived from `FlowChat::Flow` to define your conversation flow. It's recommended to place your flow definitions under `app/flow_chat`.
82
+ ### 3. Configure Routes
34
83
 
35
- For a simple "Hello World" flow:
84
+ Add the route to `config/routes.rb`:
36
85
 
37
86
  ```ruby
38
- class HelloWorldFlow < FlowChat::Flow
87
+ Rails.application.routes.draw do
88
+ post 'ussd' => 'ussd#process_request'
89
+ end
90
+ ```
91
+
92
+ ## Core Concepts
93
+
94
+ ### Flows and Screens
95
+
96
+ **Flows** are Ruby classes that define conversation logic. **Screens** represent individual interaction points where you collect user input.
97
+
98
+ ```ruby
99
+ class RegistrationFlow < FlowChat::Flow
39
100
  def main_page
40
- app.say "Hello World!"
101
+ # Each screen captures one piece of user input
102
+ phone = app.screen(:phone) do |prompt|
103
+ prompt.ask "Enter your phone number:",
104
+ validate: ->(input) { "Invalid phone number" unless valid_phone?(input) }
105
+ end
106
+
107
+ age = app.screen(:age) do |prompt|
108
+ prompt.ask "Enter your age:",
109
+ convert: ->(input) { input.to_i },
110
+ validate: ->(input) { "Must be 18 or older" unless input >= 18 }
111
+ end
112
+
113
+ # Process the collected data
114
+ create_user(phone: phone, age: age)
115
+ app.say "Registration complete!"
116
+ end
117
+
118
+ private
119
+
120
+ def valid_phone?(phone)
121
+ phone.match?(/\A\+?[\d\s\-\(\)]+\z/)
122
+ end
123
+
124
+ def create_user(phone:, age:)
125
+ # Your user creation logic here
41
126
  end
42
127
  end
43
128
  ```
44
129
 
45
- The `app` instance within `FlowChat::Flow` provides methods to interact with and respond to the user, such as `app.say`, which sends a message to the user.
130
+ ### Input Validation and Transformation
46
131
 
47
- #### Integration with USSD
132
+ FlowChat provides powerful input processing capabilities:
48
133
 
49
- Given that most USSD gateways interact via HTTP, set up a controller to handle the conversation flow:
134
+ ```ruby
135
+ app.screen(:email) do |prompt|
136
+ prompt.ask "Enter your email:",
137
+ # Transform input before validation
138
+ transform: ->(input) { input.strip.downcase },
139
+
140
+ # Validate the input
141
+ validate: ->(input) {
142
+ "Invalid email format" unless input.match?(/\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i)
143
+ },
144
+
145
+ # Convert to final format
146
+ convert: ->(input) { input }
147
+ end
148
+ ```
149
+
150
+ ### Menu Selection
151
+
152
+ Create selection menus with automatic validation:
50
153
 
51
154
  ```ruby
52
- class UssdDemoController < ApplicationController
53
- skip_forgery_protection
155
+ # Array-based choices
156
+ language = app.screen(:language) do |prompt|
157
+ prompt.select "Choose your language:", ["English", "French", "Spanish"]
158
+ end
159
+
160
+ # Hash-based choices (keys are returned values)
161
+ plan = app.screen(:plan) do |prompt|
162
+ prompt.select "Choose a plan:", {
163
+ "basic" => "Basic Plan ($10/month)",
164
+ "premium" => "Premium Plan ($25/month)",
165
+ "enterprise" => "Enterprise Plan ($100/month)"
166
+ }
167
+ end
168
+ ```
169
+
170
+ ### Yes/No Prompts
171
+
172
+ Simplified boolean input collection:
173
+
174
+ ```ruby
175
+ confirmed = app.screen(:confirmation) do |prompt|
176
+ prompt.yes? "Do you want to proceed with the payment?"
177
+ end
178
+
179
+ if confirmed
180
+ process_payment
181
+ app.say "Payment processed successfully!"
182
+ else
183
+ app.say "Payment cancelled."
184
+ end
185
+ ```
186
+
187
+ ## Advanced Features
54
188
 
55
- def hello_world
56
- ussd_processor.run HelloWorldFlow, :main_page
189
+ ### Session Management and Flow State
190
+
191
+ FlowChat automatically manages session state across requests. Each screen's result is cached, so users can navigate back and forth without losing data:
192
+
193
+ ```ruby
194
+ class OrderFlow < FlowChat::Flow
195
+ def main_page
196
+ # These values persist across requests
197
+ product = app.screen(:product) { |p| p.select "Choose product:", products }
198
+ quantity = app.screen(:quantity) { |p| p.ask "Quantity:", convert: :to_i }
199
+
200
+ # Show summary
201
+ total = calculate_total(product, quantity)
202
+ confirmed = app.screen(:confirm) do |prompt|
203
+ prompt.yes? "Order #{quantity}x #{product} for $#{total}. Confirm?"
204
+ end
205
+
206
+ if confirmed
207
+ process_order(product, quantity)
208
+ app.say "Order placed successfully!"
209
+ else
210
+ app.say "Order cancelled."
211
+ end
57
212
  end
213
+ end
214
+ ```
58
215
 
59
- private
216
+ ### Error Handling
217
+
218
+ Handle validation errors gracefully:
219
+
220
+ ```ruby
221
+ app.screen(:credit_card) do |prompt|
222
+ prompt.ask "Enter credit card number:",
223
+ validate: ->(input) {
224
+ return "Card number must be 16 digits" unless input.length == 16
225
+ return "Invalid card number" unless luhn_valid?(input)
226
+ nil # Return nil for valid input
227
+ }
228
+ end
229
+ ```
230
+
231
+ ### Middleware Configuration
232
+
233
+ FlowChat uses a **middleware architecture** to process USSD requests through a configurable pipeline. Each request flows through multiple middleware layers in a specific order.
234
+
235
+ #### Default Middleware Stack
236
+
237
+ When you run a flow, FlowChat automatically builds this middleware stack:
238
+
239
+ ```
240
+ User Input โ†’ Gateway โ†’ Session โ†’ Pagination โ†’ Custom Middleware โ†’ Executor โ†’ Flow
241
+ ```
242
+
243
+ 1. **Gateway Middleware** - Handles USSD provider communication (Nalo/Nsano)
244
+ 2. **Session Middleware** - Manages session storage and retrieval
245
+ 3. **Pagination Middleware** - Automatically splits long responses across pages
246
+ 4. **Custom Middleware** - Your application-specific middleware (optional)
247
+ 5. **Executor Middleware** - Executes the actual flow logic
248
+
249
+ #### Basic Configuration
250
+
251
+ ```ruby
252
+ processor = FlowChat::Ussd::Processor.new(self) do |config|
253
+ # Gateway configuration (required)
254
+ config.use_gateway FlowChat::Ussd::Gateway::Nalo
255
+
256
+ # Session storage (required)
257
+ config.use_session_store FlowChat::Session::RailsSessionStore
258
+
259
+ # Add custom middleware (optional)
260
+ config.use_middleware MyLoggingMiddleware
261
+
262
+ # Enable resumable sessions (optional)
263
+ config.use_resumable_sessions
264
+ end
265
+ ```
266
+
267
+ #### Runtime Middleware Modification
268
+
269
+ You can modify the middleware stack at runtime for advanced use cases:
270
+
271
+ ```ruby
272
+ processor.run(MyFlow, :main_page) do |stack|
273
+ # Add authentication middleware
274
+ stack.use AuthenticationMiddleware
275
+
276
+ # Insert rate limiting before execution
277
+ stack.insert_before FlowChat::Ussd::Middleware::Executor, RateLimitMiddleware
278
+
279
+ # Add logging after gateway
280
+ stack.insert_after gateway, RequestLoggingMiddleware
281
+ end
282
+ ```
283
+
284
+ #### Built-in Middleware
60
285
 
61
- def ussd_processor
62
- @ussd_processor ||= FlowChat::Ussd::Processor.new(self) do |processor|
63
- processor.use_gateway FlowChat::Ussd::Gateway::Nalo
64
- processor.use_session_store FlowChat::Session::RailsSessionStore
286
+ **Pagination Middleware** automatically handles responses longer than 182 characters (configurable):
287
+
288
+ ```ruby
289
+ # Configure pagination behavior
290
+ FlowChat::Config.ussd.pagination_page_size = 140 # Default: 140 characters
291
+ FlowChat::Config.ussd.pagination_next_option = "#" # Default: "#"
292
+ FlowChat::Config.ussd.pagination_next_text = "More" # Default: "More"
293
+ FlowChat::Config.ussd.pagination_back_option = "0" # Default: "0"
294
+ FlowChat::Config.ussd.pagination_back_text = "Back" # Default: "Back"
295
+ ```
296
+
297
+ **Resumable Sessions** allow users to continue interrupted conversations:
298
+
299
+ ```ruby
300
+ processor = FlowChat::Ussd::Processor.new(self) do |config|
301
+ config.use_gateway FlowChat::Ussd::Gateway::Nalo
302
+ config.use_session_store FlowChat::Session::RailsSessionStore
303
+ config.use_resumable_sessions # Enable resumable sessions
304
+ end
305
+ ```
306
+
307
+ #### Creating Custom Middleware
308
+
309
+ ```ruby
310
+ class LoggingMiddleware
311
+ def initialize(app)
312
+ @app = app
313
+ end
314
+
315
+ def call(context)
316
+ Rails.logger.info "Processing USSD request: #{context.input}"
317
+
318
+ # Call the next middleware in the stack
319
+ result = @app.call(context)
320
+
321
+ Rails.logger.info "Response: #{result[1]}"
322
+ result
323
+ end
324
+ end
325
+
326
+ # Use your custom middleware
327
+ processor = FlowChat::Ussd::Processor.new(self) do |config|
328
+ config.use_gateway FlowChat::Ussd::Gateway::Nalo
329
+ config.use_session_store FlowChat::Session::RailsSessionStore
330
+ config.use_middleware LoggingMiddleware
331
+ end
332
+ ```
333
+
334
+ ### Multiple Gateways
335
+
336
+ FlowChat supports multiple USSD gateways:
337
+
338
+ ```ruby
339
+ # Nalo Solutions Gateway
340
+ config.use_gateway FlowChat::Ussd::Gateway::Nalo
341
+
342
+ # Nsano Gateway
343
+ config.use_gateway FlowChat::Ussd::Gateway::Nsano
344
+ ```
345
+
346
+ ## Testing
347
+
348
+ ### Unit Testing Flows
349
+
350
+ Test your flows in isolation using the provided test helpers:
351
+
352
+ ```ruby
353
+ require 'test_helper'
354
+
355
+ class WelcomeFlowTest < Minitest::Test
356
+ def setup
357
+ @context = FlowChat::Context.new
358
+ @context.session = create_test_session_store
359
+ end
360
+
361
+ def test_welcome_flow_with_name
362
+ @context.input = "John Doe"
363
+ app = FlowChat::Ussd::App.new(@context)
364
+
365
+ error = assert_raises(FlowChat::Interrupt::Terminate) do
366
+ flow = WelcomeFlow.new(app)
367
+ flow.main_page
368
+ end
369
+
370
+ assert_equal "Hello, John Doe! Welcome to FlowChat.", error.prompt
371
+ end
372
+
373
+ def test_welcome_flow_without_input
374
+ @context.input = nil
375
+ app = FlowChat::Ussd::App.new(@context)
376
+
377
+ error = assert_raises(FlowChat::Interrupt::Prompt) do
378
+ flow = WelcomeFlow.new(app)
379
+ flow.main_page
65
380
  end
381
+
382
+ assert_equal "Welcome! What's your name?", error.prompt
66
383
  end
67
384
  end
68
385
  ```
69
386
 
70
- This controller initializes a `FlowChat::Ussd::Processor` specifying the use of Nalo Solutions' gateway and a session storage mechanism. Here, `RailsSessionStore` is chosen for simplicity and demonstration purposes.
387
+ ### Integration Testing
71
388
 
72
- Bind the controller action to a route:
389
+ Test complete user journeys:
73
390
 
74
391
  ```ruby
75
- Rails.application.routes.draw do
76
- post 'ussd_hello_world' => 'ussd_demo#hello_world'
392
+ class RegistrationFlowIntegrationTest < Minitest::Test
393
+ def test_complete_registration_flow
394
+ controller = mock_controller
395
+ processor = FlowChat::Ussd::Processor.new(controller) do |config|
396
+ config.use_gateway MockGateway
397
+ config.use_session_store FlowChat::Session::RailsSessionStore
398
+ end
399
+
400
+ # Simulate the complete flow
401
+ # First request - ask for phone
402
+ # Second request - provide phone, ask for age
403
+ # Third request - provide age, complete registration
404
+ end
405
+ end
406
+ ```
407
+
408
+ ### Testing Middleware
409
+
410
+ Test your custom middleware in isolation:
411
+
412
+ ```ruby
413
+ class LoggingMiddlewareTest < Minitest::Test
414
+ def test_logs_request_and_response
415
+ # Mock the next app in the chain
416
+ app = lambda { |context| [:prompt, "Test response", []] }
417
+ middleware = LoggingMiddleware.new(app)
418
+
419
+ context = FlowChat::Context.new
420
+ context.input = "test input"
421
+
422
+ # Capture log output
423
+ log_output = StringIO.new
424
+ Rails.stub(:logger, Logger.new(log_output)) do
425
+ type, prompt, choices = middleware.call(context)
426
+
427
+ assert_equal :prompt, type
428
+ assert_equal "Test response", prompt
429
+ assert_includes log_output.string, "Processing USSD request: test input"
430
+ assert_includes log_output.string, "Response: Test response"
431
+ end
432
+ end
433
+ end
434
+ ```
435
+
436
+ ### Testing Middleware Stack Modification
437
+
438
+ Test runtime middleware modifications:
439
+
440
+ ```ruby
441
+ class ProcessorMiddlewareTest < Minitest::Test
442
+ def test_custom_middleware_insertion
443
+ controller = mock_controller
444
+ processor = FlowChat::Ussd::Processor.new(controller) do |config|
445
+ config.use_gateway MockGateway
446
+ config.use_session_store FlowChat::Session::RailsSessionStore
447
+ end
448
+
449
+ custom_middleware_called = false
450
+ custom_middleware = Class.new do
451
+ define_method(:initialize) { |app| @app = app }
452
+ define_method(:call) do |context|
453
+ custom_middleware_called = true
454
+ @app.call(context)
455
+ end
456
+ end
457
+
458
+ processor.run(TestFlow, :main_page) do |stack|
459
+ stack.use custom_middleware
460
+ stack.insert_before FlowChat::Ussd::Middleware::Executor, custom_middleware
461
+ end
462
+
463
+ assert custom_middleware_called, "Custom middleware should have been executed"
464
+ end
77
465
  end
78
466
  ```
79
467
 
80
- #### Testing with the USSD Simulator
468
+ ### USSD Simulator
81
469
 
82
- FlowChat comes with a USSD simulator for local testing:
470
+ Use the built-in simulator for interactive testing:
83
471
 
84
472
  ```ruby
85
473
  class UssdSimulatorController < ApplicationController
@@ -88,7 +476,7 @@ class UssdSimulatorController < ApplicationController
88
476
  protected
89
477
 
90
478
  def default_endpoint
91
- '/ussd_hello_world'
479
+ '/ussd'
92
480
  end
93
481
 
94
482
  def default_provider
@@ -97,62 +485,145 @@ class UssdSimulatorController < ApplicationController
97
485
  end
98
486
  ```
99
487
 
100
- And set up the corresponding route:
488
+ Add to routes and visit `http://localhost:3000/ussd_simulator`.
489
+
490
+ ## Best Practices
491
+
492
+ ### 1. Keep Flows Focused
493
+
494
+ Create separate flows for different user journeys:
101
495
 
102
496
  ```ruby
103
- Rails.application.routes.draw do
104
- get 'ussd_simulator' => 'ussd_simulator#ussd_simulator'
497
+ # Good: Focused flows
498
+ class LoginFlow < FlowChat::Flow
499
+ # Handle user authentication
500
+ end
501
+
502
+ class RegistrationFlow < FlowChat::Flow
503
+ # Handle user registration
504
+ end
505
+
506
+ class AccountFlow < FlowChat::Flow
507
+ # Handle account management
105
508
  end
106
509
  ```
107
510
 
108
- Visit [http://localhost:3000/ussd_simulator](http://localhost:3000/ussd_simulator) to initiate and test your flow.
511
+ ### 2. Use Descriptive Screen Names
512
+
513
+ Screen names should clearly indicate their purpose:
514
+
515
+ ```ruby
516
+ # Good
517
+ app.screen(:customer_phone_number) { |p| p.ask "Phone:" }
518
+ app.screen(:payment_confirmation) { |p| p.yes? "Confirm payment?" }
519
+
520
+ # Avoid
521
+ app.screen(:input1) { |p| p.ask "Phone:" }
522
+ app.screen(:confirm) { |p| p.yes? "Confirm payment?" }
523
+ ```
109
524
 
110
- ### Advanced Usage: Implementing Multiple Screens
525
+ ### 3. Validate Early and Often
111
526
 
112
- To engage users with a multi-step interaction, define a flow with multiple screens:
527
+ Always validate user input to provide clear feedback:
113
528
 
114
529
  ```ruby
115
- class MultipleScreensFlow < FlowChat::Flow
116
- def main_page
117
- name = app.screen(:name) { |prompt|
118
- prompt.ask "What is your name?", transform: ->(input) { input.squish }
530
+ app.screen(:amount) do |prompt|
531
+ prompt.ask "Enter amount:",
532
+ convert: ->(input) { input.to_f },
533
+ validate: ->(amount) {
534
+ return "Amount must be positive" if amount <= 0
535
+ return "Maximum amount is $1000" if amount > 1000
536
+ nil
119
537
  }
538
+ end
539
+ ```
120
540
 
121
- age = app.screen(:age) do |prompt|
122
- prompt.ask "How old are you?",
123
- convert: ->(input) { input.to_i },
124
- validate: ->(input) { "You must be at least 13 years old" unless input >= 13 }
125
- end
126
-
127
- gender = app.screen(:gender) { |prompt| prompt.select "What is your gender", ["Male", "Female"] }
541
+ ### 4. Handle Edge Cases
128
542
 
129
- confirm = app.screen(:confirm) do |prompt|
130
- prompt.yes?("Is this correct?\n\nName: #{name}\nAge: #{age}\nGender: #{gender}")
131
- end
543
+ Consider error scenarios and provide helpful messages:
132
544
 
133
- app.say confirm ? "Thank you for confirming" : "Please try again"
545
+ ```ruby
546
+ def main_page
547
+ begin
548
+ process_user_request
549
+ rescue PaymentError => e
550
+ app.say "Payment failed: #{e.message}. Please try again."
551
+ rescue SystemError
552
+ app.say "System temporarily unavailable. Please try again later."
134
553
  end
135
554
  end
136
555
  ```
137
556
 
138
- This example illustrates a flow that collects and confirms user information across multiple interaction steps, showcasing FlowChat's capability to handle complex conversation logic effortlessly.
557
+ ## Configuration
558
+
559
+ ### Session Storage Options
139
560
 
140
- TODO:
561
+ Configure different session storage backends:
562
+
563
+ ```ruby
564
+ # Rails session (default)
565
+ config.use_session_store FlowChat::Session::RailsSessionStore
566
+
567
+ # Custom session store
568
+ class MySessionStore
569
+ def initialize(context)
570
+ @context = context
571
+ end
572
+
573
+ def get(key)
574
+ # Your storage logic
575
+ end
141
576
 
142
- ### Sub Flows
577
+ def set(key, value)
578
+ # Your storage logic
579
+ end
580
+ end
143
581
 
144
- TODO:
582
+ config.use_session_store MySessionStore
583
+ ```
145
584
 
146
585
  ## Development
147
586
 
148
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
587
+ ### Running Tests
588
+
589
+ FlowChat uses Minitest for testing:
590
+
591
+ ```bash
592
+ # Run all tests
593
+ bundle exec rake test
594
+
595
+ # Run specific test file
596
+ bundle exec rake test TEST=test/unit/flow_test.rb
597
+
598
+ # Run specific test
599
+ bundle exec rake test TESTOPTS="--name=test_flow_initialization"
600
+ ```
601
+
602
+ ### Contributing
149
603
 
150
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
604
+ 1. Fork the repository
605
+ 2. Create your feature branch (`git checkout -b feature/amazing-feature`)
606
+ 3. Add tests for your changes
607
+ 4. Ensure all tests pass (`bundle exec rake test`)
608
+ 5. Commit your changes (`git commit -am 'Add amazing feature'`)
609
+ 6. Push to the branch (`git push origin feature/amazing-feature`)
610
+ 7. Open a Pull Request
151
611
 
152
- ## Contributing
612
+ ## Roadmap
153
613
 
154
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/flow_chat.
614
+ - ๐Ÿ“ฑ **WhatsApp Integration** - Support for WhatsApp Business API
615
+ - ๐Ÿ’ฌ **Telegram Bot Support** - Native Telegram bot integration
616
+ - ๐Ÿ”„ **Sub-flows** - Reusable conversation components
617
+ - ๐Ÿ“Š **Analytics Integration** - Built-in conversation analytics
618
+ - ๐ŸŒ **Multi-language Support** - Internationalization features
619
+ - โšก **Performance Optimizations** - Improved middleware performance
155
620
 
156
621
  ## License
157
622
 
158
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
623
+ FlowChat is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
624
+
625
+ ## Support
626
+
627
+ - ๐Ÿ“– **Documentation**: [GitHub Repository](https://github.com/radioactive-labs/flow_chat)
628
+ - ๐Ÿ› **Bug Reports**: [GitHub Issues](https://github.com/radioactive-labs/flow_chat/issues)
629
+ - ๐Ÿ’ฌ **Community**: Join our discussions for help and feature requests
data/Rakefile CHANGED
@@ -1,6 +1,10 @@
1
1
  require "bundler/gem_tasks"
2
- require "rspec/core/rake_task"
2
+ require "rake/testtask"
3
3
 
4
- RSpec::Core::RakeTask.new(:spec)
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.libs << "lib"
7
+ t.test_files = FileList["test/**/*_test.rb"]
8
+ end
5
9
 
6
- task default: :spec
10
+ task default: :test
@@ -1,16 +1,29 @@
1
1
  module FlowChat
2
2
  module Config
3
+ # General framework configuration
3
4
  mattr_accessor :logger, default: Logger.new($stdout)
4
5
  mattr_accessor :cache, default: nil
5
6
 
6
- mattr_accessor :pagination_page_size, default: 140
7
- mattr_accessor :pagination_back_option, default: "0"
8
- mattr_accessor :pagination_back_text, default: "Back"
9
- mattr_accessor :pagination_next_option, default: "#"
10
- mattr_accessor :pagination_next_text, default: "More"
7
+ # USSD-specific configuration object
8
+ def self.ussd
9
+ @ussd ||= UssdConfig.new
10
+ end
11
11
 
12
- mattr_accessor :resumable_sessions_enabled, default: false
13
- mattr_accessor :resumable_sessions_global, default: true
14
- mattr_accessor :resumable_sessions_timeout_seconds, default: 300
12
+ class UssdConfig
13
+ attr_accessor :pagination_page_size, :pagination_back_option, :pagination_back_text,
14
+ :pagination_next_option, :pagination_next_text,
15
+ :resumable_sessions_enabled, :resumable_sessions_global, :resumable_sessions_timeout_seconds
16
+
17
+ def initialize
18
+ @pagination_page_size = 140
19
+ @pagination_back_option = "0"
20
+ @pagination_back_text = "Back"
21
+ @pagination_next_option = "#"
22
+ @pagination_next_text = "More"
23
+ @resumable_sessions_enabled = false
24
+ @resumable_sessions_global = true
25
+ @resumable_sessions_timeout_seconds = 300
26
+ end
27
+ end
15
28
  end
16
29
  end
@@ -14,8 +14,16 @@ module FlowChat
14
14
 
15
15
  def input = @data["request.input"]
16
16
 
17
+ def input=(value)
18
+ @data["request.input"] = value
19
+ end
20
+
17
21
  def session = @data["session"]
18
22
 
23
+ def session=(value)
24
+ @data["session"] = value
25
+ end
26
+
19
27
  def controller = @data["controller"]
20
28
 
21
29
  # def request = controller.request
@@ -1,5 +1,6 @@
1
1
  module FlowChat
2
2
  module Interrupt
3
+ # standard:disable Lint/InheritException
3
4
  class Base < Exception
4
5
  attr_reader :prompt
5
6
 
@@ -8,6 +9,7 @@ module FlowChat
8
9
  super
9
10
  end
10
11
  end
12
+ # standard:enable Lint/InheritException
11
13
 
12
14
  class Prompt < Base
13
15
  attr_reader :choices
@@ -7,7 +7,7 @@ module FlowChat
7
7
 
8
8
  def call(context)
9
9
  context["session.id"] = session_id context
10
- context["session"] = context["session.store"].new context
10
+ context.session = context["session.store"].new(context)
11
11
  @app.call(context)
12
12
  end
13
13
 
@@ -16,7 +16,7 @@ module FlowChat
16
16
  context["request.network"] = nil
17
17
  context["request.msisdn"] = Phonelib.parse(params["MSISDN"]).e164
18
18
  # context["request.type"] = params["MSGTYPE"] ? :initial : :response
19
- context["request.input"] = params["USERDATA"].presence
19
+ context.input = params["USERDATA"].presence
20
20
 
21
21
  type, prompt, choices = @app.call(context)
22
22
 
@@ -10,7 +10,7 @@ module FlowChat
10
10
 
11
11
  def call(context)
12
12
  controller = context["controller"]
13
- request = controller.request
13
+ controller.request
14
14
 
15
15
  # input = context["rack.input"].read
16
16
  # context["rack.input"].rewind
@@ -12,16 +12,14 @@ module FlowChat
12
12
 
13
13
  if intercept?
14
14
  type, prompt = handle_intercepted_request
15
- [type, prompt, []]
16
15
  else
17
16
  @session.delete "ussd.pagination"
18
17
  type, prompt, choices = @app.call(context)
19
18
 
20
19
  prompt = FlowChat::Ussd::Renderer.new(prompt, choices).render
21
20
  type, prompt = maybe_paginate(type, prompt) if prompt.present?
22
-
23
- [type, prompt, []]
24
21
  end
22
+ [type, prompt, []]
25
23
  end
26
24
 
27
25
  private
@@ -29,11 +27,11 @@ module FlowChat
29
27
  def intercept?
30
28
  pagination_state.present? &&
31
29
  (pagination_state["type"].to_sym == :terminal ||
32
- ([Config.pagination_next_option, Config.pagination_back_option].include? @context.input))
30
+ ([FlowChat::Config.ussd.pagination_next_option, FlowChat::Config.ussd.pagination_back_option].include? @context.input))
33
31
  end
34
32
 
35
33
  def handle_intercepted_request
36
- Config.logger&.info "FlowChat::Middleware::Pagination :: Intercepted to handle pagination"
34
+ FlowChat::Config.logger&.info "FlowChat::Middleware::Pagination :: Intercepted to handle pagination"
37
35
  start, finish, has_more = calculate_offsets
38
36
  type = (pagination_state["type"].to_sym == :terminal && !has_more) ? :terminal : :prompt
39
37
  prompt = pagination_state["prompt"][start..finish].strip + build_pagination_options(type, has_more)
@@ -43,9 +41,9 @@ module FlowChat
43
41
  end
44
42
 
45
43
  def maybe_paginate(type, prompt)
46
- if prompt.length > Config.pagination_page_size
44
+ if prompt.length > FlowChat::Config.ussd.pagination_page_size
47
45
  original_prompt = prompt
48
- Config.logger&.info "FlowChat::Middleware::Pagination :: Response length (#{prompt.length}) exceeds page size (#{Config.pagination_page_size}). Paginating."
46
+ FlowChat::Config.logger&.info "FlowChat::Middleware::Pagination :: Response length (#{prompt.length}) exceeds page size (#{FlowChat::Config.ussd.pagination_page_size}). Paginating."
49
47
  prompt = prompt[0..single_option_slice_size]
50
48
  # Ensure we do not cut words and options off in the middle.
51
49
  current_pagebreak = prompt[single_option_slice_size + 1].blank? ? single_option_slice_size : prompt.rindex("\n") || prompt.rindex(" ") || single_option_slice_size
@@ -60,12 +58,12 @@ module FlowChat
60
58
  page = current_page
61
59
  offset = pagination_state["offsets"][page.to_s]
62
60
  if offset.present?
63
- Config.logger&.debug "FlowChat::Middleware::Pagination :: Reusing cached offset for page: #{page}"
61
+ FlowChat::Config.logger&.debug "FlowChat::Middleware::Pagination :: Reusing cached offset for page: #{page}"
64
62
  start = offset["start"]
65
63
  finish = offset["finish"]
66
64
  has_more = pagination_state["prompt"].length > finish
67
65
  else
68
- Config.logger&.debug "FlowChat::Middleware::Pagination :: Calculating offset for page: #{page}"
66
+ FlowChat::Config.logger&.debug "FlowChat::Middleware::Pagination :: Calculating offset for page: #{page}"
69
67
  # We are guaranteed a previous offset because it was set in maybe_paginate
70
68
  previous_page = page - 1
71
69
  previous_offset = pagination_state["offsets"][previous_page.to_s]
@@ -73,8 +71,7 @@ module FlowChat
73
71
  has_more, len = (pagination_state["prompt"].length > start + single_option_slice_size) ? [true, dual_options_slice_size] : [false, single_option_slice_size]
74
72
  finish = start + len
75
73
  if start > pagination_state["prompt"].length
76
- Config.logger&.debug "FlowChat::Middleware::Pagination :: No content exists for page: #{page}. Reverting to page: #{page - 1}"
77
- page -= 1
74
+ FlowChat::Config.logger&.debug "FlowChat::Middleware::Pagination :: No content exists for page: #{page}. Reverting to page: #{page - 1}"
78
75
  has_more = false
79
76
  start = previous_offset["start"]
80
77
  finish = previous_offset["finish"]
@@ -100,11 +97,11 @@ module FlowChat
100
97
  end
101
98
 
102
99
  def next_option
103
- "#{Config.pagination_next_option} #{Config.pagination_next_text}"
100
+ "#{FlowChat::Config.ussd.pagination_next_option} #{FlowChat::Config.ussd.pagination_next_text}"
104
101
  end
105
102
 
106
103
  def back_option
107
- "#{Config.pagination_back_option} #{Config.pagination_back_text}"
104
+ "#{FlowChat::Config.ussd.pagination_back_option} #{FlowChat::Config.ussd.pagination_back_text}"
108
105
  end
109
106
 
110
107
  def single_option_slice_size
@@ -112,7 +109,7 @@ module FlowChat
112
109
  # To display a single back or next option
113
110
  # We accomodate the 2 newlines and the longest of the options
114
111
  # We subtract an additional 1 to normalize it for slicing
115
- @single_option_slice_size = Config.pagination_page_size - 2 - [next_option.length, back_option.length].max - 1
112
+ @single_option_slice_size = FlowChat::Config.ussd.pagination_page_size - 2 - [next_option.length, back_option.length].max - 1
116
113
  end
117
114
  @single_option_slice_size
118
115
  end
@@ -121,16 +118,16 @@ module FlowChat
121
118
  unless @dual_options_slice_size.present?
122
119
  # To display both back and next options
123
120
  # We accomodate the 3 newlines and both of the options
124
- @dual_options_slice_size = Config.pagination_page_size - 3 - [next_option.length, back_option.length].sum - 1
121
+ @dual_options_slice_size = FlowChat::Config.ussd.pagination_page_size - 3 - [next_option.length, back_option.length].sum - 1
125
122
  end
126
123
  @dual_options_slice_size
127
124
  end
128
125
 
129
126
  def current_page
130
127
  page = pagination_state["page"]
131
- if @context.input == Config.pagination_back_option
128
+ if @context.input == FlowChat::Config.ussd.pagination_back_option
132
129
  page -= 1
133
- elsif @context.input == Config.pagination_next_option
130
+ elsif @context.input == FlowChat::Config.ussd.pagination_next_option
134
131
  page += 1
135
132
  end
136
133
  [page, 1].max
@@ -7,44 +7,31 @@ module FlowChat
7
7
  end
8
8
 
9
9
  def call(context)
10
- if Config.resumable_sessions_enabled && context["ussd.request"].present?
11
- request = Rack::Request.new(context)
12
- session = request.session
13
-
14
- context["ussd.resumable_sessions"] = {}
15
-
16
- # If this is a new session but we have the flag set, this means the call terminated before
17
- # the session closed. Force it to resume.
18
- # This is safe since a new session is started if the old session does not indeed exist.
19
- if context["ussd.request"][:type] == :initial && can_resume_session?(session)
20
- context["ussd.request"][:type] = :response
21
- context["ussd.resumable_sessions"][:resumed] = true
10
+ if FlowChat::Config.ussd.resumable_sessions_enabled && context["ussd.request"].present?
11
+ # First, try to find any interruption session.
12
+ # The session key can be:
13
+ # - a global session (key: "global")
14
+ # - a provider-specific session (key: <provider>)
15
+ session_key = self.class.session_key(context)
16
+ resumable_session = context["session.store"].get(session_key)
17
+
18
+ if resumable_session.present? && valid?(resumable_session)
19
+ context.merge! resumable_session
22
20
  end
23
-
24
- res = @app.call(context)
25
-
26
- if context["ussd.response"].present?
27
- if context["ussd.response"][:type] == :terminal || context["ussd.resumable_sessions"][:disable]
28
- session.delete "ussd.resumable_sessions"
29
- else
30
- session["ussd.resumable_sessions"] = Time.now.to_i
31
- end
32
- end
33
-
34
- res
35
- else
36
- @app.call(context)
37
21
  end
22
+
23
+ @app.call(context)
38
24
  end
39
25
 
40
26
  private
41
27
 
42
- def can_resume_session?(session)
43
- return unless session["ussd.resumable_sessions"].present?
44
- return true unless Config.resumable_sessions_timeout_seconds
28
+ def valid?(session)
29
+ return true unless FlowChat::Config.ussd.resumable_sessions_timeout_seconds
45
30
 
46
- last_active_at = Time.at(session["ussd.resumable_sessions"])
47
- (Time.now - Config.resumable_sessions_timeout_seconds) < last_active_at
31
+ last_active_at = Time.parse session.dig("context", "last_active_at")
32
+ (Time.now - FlowChat::Config.ussd.resumable_sessions_timeout_seconds) < last_active_at
33
+ rescue
34
+ false
48
35
  end
49
36
  end
50
37
  end
@@ -32,7 +32,7 @@ module FlowChat
32
32
  msg,
33
33
  choices: choices_prompt,
34
34
  convert: lambda { |choice| choice.to_i },
35
- validate: lambda { |choice| "Invalid selection:" unless (1..choices.size).include?(choice) },
35
+ validate: lambda { |choice| "Invalid selection:" unless (1..choices.size).cover?(choice) },
36
36
  transform: lambda { |choice| choices[choice - 1] }
37
37
  )
38
38
  end
@@ -38,7 +38,7 @@ module FlowChat
38
38
 
39
39
  def simulator_locals
40
40
  {
41
- pagesize: Config.pagination_page_size,
41
+ pagesize: FlowChat::Config.ussd.pagination_page_size,
42
42
  show_options: show_options,
43
43
  default_msisdn: default_msisdn,
44
44
  default_endpoint: default_endpoint,
@@ -1,3 +1,3 @@
1
1
  module FlowChat
2
- VERSION = "0.2.1"
2
+ VERSION = "0.3.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: flow_chat
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stefan Froelich
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-04-05 00:00:00.000000000 Z
11
+ date: 2025-06-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: zeitwerk
@@ -89,7 +89,7 @@ extra_rdoc_files: []
89
89
  files:
90
90
  - ".DS_Store"
91
91
  - ".gitignore"
92
- - ".rspec"
92
+ - ".ruby-version"
93
93
  - ".travis.yml"
94
94
  - Gemfile
95
95
  - LICENSE.txt
@@ -141,7 +141,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
141
141
  - !ruby/object:Gem::Version
142
142
  version: '0'
143
143
  requirements: []
144
- rubygems_version: 3.5.6
144
+ rubygems_version: 3.4.10
145
145
  signing_key:
146
146
  specification_version: 4
147
147
  summary: Framework for building Menu based conversations (e.g. USSD) in Rails.
data/.rspec DELETED
@@ -1,3 +0,0 @@
1
- --format documentation
2
- --color
3
- --require spec_helper