flow_chat 0.2.0 โ†’ 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: 6eba909e6ae38dfeb26be6f854b185ca2c8097397aa4eea8a094d47e1d3fcd56
4
- data.tar.gz: c47e6bf434684b87c7f0c8b2ebb9b6536c5bc17efe46fa3bec3876b976401f96
3
+ metadata.gz: 31c7630d410bbcff17307ade16634f5d5c9662f91eeeab8365b98681886de6b3
4
+ data.tar.gz: 4a7d4059a7a60dee5af2b189fa33c4638a439af903a72f38ffa5a67f9325115d
5
5
  SHA512:
6
- metadata.gz: f52ba0115aa8000f9de120d436c9800318c8635efa86979b17f2dc01104d6ae65f66dcc193ada68a0a36ff238e9b6a87ef9ee66e00581ca4b5faa72a31fb25a0
7
- data.tar.gz: 508138465e83f318e8e889f4e11ea19a709fc06c310b76c44388f56b84c94521fa44b45cc734fcfc07acf3b1cf38bbc764614e1d7252ee7b40ab4e0a20c55877
6
+ metadata.gz: 6040ccd725fa1a24b7ff46b5d288e11a1f1a4a2d58b4e773a843153a7d2cf3fb7c1fd7971dd27ed2bc938bf12da17a42b9bdbee9db06152507051eca57592fdb
7
+ data.tar.gz: 002d2fc1f5b6ea0b20e4229220801bc94311b5237ce24891a571f6f544abaf072a6fdf71de15632069a40d1b29b58e349f937b80ff3975f97bd3a607053b2c39
data/.DS_Store ADDED
Binary file
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,39 +1,629 @@
1
1
  # FlowChat
2
2
 
3
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/flow_chat`. To experiment with that code, run `bin/console` for an interactive prompt.
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
- TODO: Delete this and the text above, and describe your gem
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
12
+
13
+ ## Architecture Overview
14
+
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.
16
+
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
6
29
 
7
30
  ## Installation
8
31
 
9
- Add this line to your application's Gemfile:
32
+ Add FlowChat to your Rails application's Gemfile:
10
33
 
11
34
  ```ruby
12
35
  gem 'flow_chat'
13
36
  ```
14
37
 
15
- And then execute:
38
+ Then execute:
39
+
40
+ ```bash
41
+ bundle install
42
+ ```
43
+
44
+ ## Quick Start
45
+
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
61
+ ```
62
+
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
76
+
77
+ processor.run WelcomeFlow, :main_page
78
+ end
79
+ end
80
+ ```
81
+
82
+ ### 3. Configure Routes
83
+
84
+ Add the route to `config/routes.rb`:
85
+
86
+ ```ruby
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
100
+ def main_page
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
126
+ end
127
+ end
128
+ ```
129
+
130
+ ### Input Validation and Transformation
131
+
132
+ FlowChat provides powerful input processing capabilities:
133
+
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:
153
+
154
+ ```ruby
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
188
+
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
212
+ end
213
+ end
214
+ ```
215
+
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
285
+
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
380
+ end
381
+
382
+ assert_equal "Welcome! What's your name?", error.prompt
383
+ end
384
+ end
385
+ ```
386
+
387
+ ### Integration Testing
388
+
389
+ Test complete user journeys:
390
+
391
+ ```ruby
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
465
+ end
466
+ ```
467
+
468
+ ### USSD Simulator
469
+
470
+ Use the built-in simulator for interactive testing:
471
+
472
+ ```ruby
473
+ class UssdSimulatorController < ApplicationController
474
+ include FlowChat::Ussd::Simulator::Controller
475
+
476
+ protected
477
+
478
+ def default_endpoint
479
+ '/ussd'
480
+ end
481
+
482
+ def default_provider
483
+ :nalo
484
+ end
485
+ end
486
+ ```
487
+
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:
495
+
496
+ ```ruby
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
508
+ end
509
+ ```
510
+
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
+ ```
524
+
525
+ ### 3. Validate Early and Often
16
526
 
17
- $ bundle install
527
+ Always validate user input to provide clear feedback:
18
528
 
19
- Or install it yourself as:
529
+ ```ruby
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
537
+ }
538
+ end
539
+ ```
540
+
541
+ ### 4. Handle Edge Cases
542
+
543
+ Consider error scenarios and provide helpful messages:
544
+
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."
553
+ end
554
+ end
555
+ ```
556
+
557
+ ## Configuration
558
+
559
+ ### Session Storage Options
560
+
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
20
572
 
21
- $ gem install flow_chat
573
+ def get(key)
574
+ # Your storage logic
575
+ end
22
576
 
23
- ## Usage
577
+ def set(key, value)
578
+ # Your storage logic
579
+ end
580
+ end
24
581
 
25
- TODO: Write usage instructions here
582
+ config.use_session_store MySessionStore
583
+ ```
26
584
 
27
585
  ## Development
28
586
 
29
- 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
30
588
 
31
- 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).
589
+ FlowChat uses Minitest for testing:
32
590
 
33
- ## Contributing
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
+ ```
34
601
 
35
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/flow_chat.
602
+ ### Contributing
603
+
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
611
+
612
+ ## Roadmap
613
+
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
36
620
 
37
621
  ## License
38
622
 
39
- 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
data/flow_chat.gemspec CHANGED
@@ -6,8 +6,8 @@ Gem::Specification.new do |spec|
6
6
  spec.authors = ["Stefan Froelich"]
7
7
  spec.email = ["sfroelich01@gmail.com"]
8
8
 
9
- spec.summary = "Framework for processing Menu based conversations e.g. USSD in Rails."
10
- spec.description = "Framework for processing Menu based conversations e.g. USSD in Rails."
9
+ spec.summary = "Framework for building Menu based conversations (e.g. USSD) in Rails."
10
+ spec.description = "Framework for building Menu based conversations (e.g. USSD) in Rails."
11
11
  spec.homepage = "https://github.com/radioactive-labs/flow_chat"
12
12
  spec.license = "MIT"
13
13
  spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0")
Binary file
@@ -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
@@ -0,0 +1,9 @@
1
+ module FlowChat
2
+ class Flow
3
+ attr_reader :app
4
+
5
+ def initialize(app)
6
+ @app = app
7
+ end
8
+ end
9
+ end
@@ -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
 
@@ -25,7 +25,7 @@ module FlowChat
25
25
  value
26
26
  end
27
27
 
28
- def terminate!(msg)
28
+ def say(msg)
29
29
  raise FlowChat::Interrupt::Terminate.new(msg)
30
30
  end
31
31
  end
@@ -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,
@@ -40,22 +40,22 @@
40
40
  </head>
41
41
  <body>
42
42
  <div class="content">
43
- <div class="field hidden<%= show_options ? '' : 'hidden' %>">
43
+ <div class="field <%= show_options ? '' : 'hidden' %>">
44
44
  <div class="label">Provider </div>
45
45
  <div class="value">
46
46
  <select id="provider">
47
- <option <%= default_provider == :nalo ? 'selected' : '' %> value="nalo">Nalo</option>
48
- <option <%= default_provider == :nsano ? 'selected' : '' %> value="nsano">Nsano</option>
47
+ <option <%= default_provider == :nalo ? 'selected' : '' %> value="nalo">Nalo</option>
48
+ <option <%= default_provider == :nsano ? 'selected' : '' %> value="nsano">Nsano</option>
49
49
  </select>
50
50
  </div>
51
51
  </div>
52
- <div class="field <%= show_options ? '' : 'hidden' %>">
52
+ <div class="field <%= show_options ? '' : 'hidden' %>">
53
53
  <div class="label">Endpoint </div>
54
54
  <div class="value">
55
55
  <input id="endpoint" value="<%= default_endpoint %>" />
56
56
  </div>
57
57
  </div>
58
- <div class="field <%= show_options ? '' : 'hidden' %>">
58
+ <div class="field <%= show_options ? '' : 'hidden' %>">
59
59
  <div class="label">MSISDN </div>
60
60
  <div class="value">
61
61
  <input id="msisdn" value="<%= default_msisdn %>" />
@@ -1,3 +1,3 @@
1
1
  module FlowChat
2
- VERSION = "0.2.0"
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.0
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
@@ -80,15 +80,16 @@ dependencies:
80
80
  - - "~>"
81
81
  - !ruby/object:Gem::Version
82
82
  version: 0.4.2
83
- description: Framework for processing Menu based conversations e.g. USSD in Rails.
83
+ description: Framework for building Menu based conversations (e.g. USSD) in Rails.
84
84
  email:
85
85
  - sfroelich01@gmail.com
86
86
  executables: []
87
87
  extensions: []
88
88
  extra_rdoc_files: []
89
89
  files:
90
+ - ".DS_Store"
90
91
  - ".gitignore"
91
- - ".rspec"
92
+ - ".ruby-version"
92
93
  - ".travis.yml"
93
94
  - Gemfile
94
95
  - LICENSE.txt
@@ -97,9 +98,11 @@ files:
97
98
  - bin/console
98
99
  - bin/setup
99
100
  - flow_chat.gemspec
101
+ - images/ussd_simulator.png
100
102
  - lib/flow_chat.rb
101
103
  - lib/flow_chat/config.rb
102
104
  - lib/flow_chat/context.rb
105
+ - lib/flow_chat/flow.rb
103
106
  - lib/flow_chat/interrupt.rb
104
107
  - lib/flow_chat/session/middleware.rb
105
108
  - lib/flow_chat/session/rails_session_store.rb
@@ -138,8 +141,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
138
141
  - !ruby/object:Gem::Version
139
142
  version: '0'
140
143
  requirements: []
141
- rubygems_version: 3.5.6
144
+ rubygems_version: 3.4.10
142
145
  signing_key:
143
146
  specification_version: 4
144
- summary: Framework for processing Menu based conversations e.g. USSD in Rails.
147
+ summary: Framework for building Menu based conversations (e.g. USSD) in Rails.
145
148
  test_files: []
data/.rspec DELETED
@@ -1,3 +0,0 @@
1
- --format documentation
2
- --color
3
- --require spec_helper