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 +4 -4
- data/.ruby-version +1 -0
- data/Gemfile +3 -1
- data/README.md +536 -65
- data/Rakefile +7 -3
- data/lib/flow_chat/config.rb +21 -8
- data/lib/flow_chat/context.rb +8 -0
- data/lib/flow_chat/interrupt.rb +2 -0
- data/lib/flow_chat/session/middleware.rb +1 -1
- data/lib/flow_chat/ussd/gateway/nalo.rb +1 -1
- data/lib/flow_chat/ussd/gateway/nsano.rb +1 -1
- data/lib/flow_chat/ussd/middleware/pagination.rb +14 -17
- data/lib/flow_chat/ussd/middleware/resumable_session.rb +18 -31
- data/lib/flow_chat/ussd/prompt.rb +1 -1
- data/lib/flow_chat/ussd/simulator/controller.rb +1 -1
- data/lib/flow_chat/version.rb +1 -1
- metadata +4 -4
- data/.rspec +0 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 31c7630d410bbcff17307ade16634f5d5c9662f91eeeab8365b98681886de6b3
|
4
|
+
data.tar.gz: 4a7d4059a7a60dee5af2b189fa33c4638a439af903a72f38ffa5a67f9325115d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 6040ccd725fa1a24b7ff46b5d288e11a1f1a4a2d58b4e773a843153a7d2cf3fb7c1fd7971dd27ed2bc938bf12da17a42b9bdbee9db06152507051eca57592fdb
|
7
|
+
data.tar.gz: 002d2fc1f5b6ea0b20e4229220801bc94311b5237ce24891a571f6f544abaf072a6fdf71de15632069a40d1b29b58e349f937b80ff3975f97bd3a607053b2c39
|
data/.ruby-version
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
3.2.2
|
data/Gemfile
CHANGED
data/README.md
CHANGED
@@ -1,85 +1,473 @@
|
|
1
1
|
# FlowChat
|
2
2
|
|
3
|
-
FlowChat is a Rails framework designed for
|
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
|
-
|
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
|
-
##
|
13
|
+
## Architecture Overview
|
8
14
|
|
9
|
-
|
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
|
-
|
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'
|
35
|
+
gem 'flow_chat'
|
15
36
|
```
|
16
37
|
|
17
|
-
Then
|
38
|
+
Then execute:
|
18
39
|
|
19
40
|
```bash
|
20
41
|
bundle install
|
21
42
|
```
|
22
43
|
|
23
|
-
|
44
|
+
## Quick Start
|
24
45
|
|
25
|
-
|
26
|
-
|
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
|
-
###
|
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
|
-
|
77
|
+
processor.run WelcomeFlow, :main_page
|
78
|
+
end
|
79
|
+
end
|
80
|
+
```
|
32
81
|
|
33
|
-
|
82
|
+
### 3. Configure Routes
|
34
83
|
|
35
|
-
|
84
|
+
Add the route to `config/routes.rb`:
|
36
85
|
|
37
86
|
```ruby
|
38
|
-
|
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
|
-
|
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
|
-
|
130
|
+
### Input Validation and Transformation
|
46
131
|
|
47
|
-
|
132
|
+
FlowChat provides powerful input processing capabilities:
|
48
133
|
|
49
|
-
|
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
|
-
|
53
|
-
|
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
|
-
|
56
|
-
|
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
|
-
|
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
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
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
|
-
|
387
|
+
### Integration Testing
|
71
388
|
|
72
|
-
|
389
|
+
Test complete user journeys:
|
73
390
|
|
74
391
|
```ruby
|
75
|
-
|
76
|
-
|
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
|
-
|
468
|
+
### USSD Simulator
|
81
469
|
|
82
|
-
|
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
|
-
'/
|
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
|
-
|
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
|
-
|
104
|
-
|
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
|
-
|
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
|
-
###
|
525
|
+
### 3. Validate Early and Often
|
111
526
|
|
112
|
-
|
527
|
+
Always validate user input to provide clear feedback:
|
113
528
|
|
114
529
|
```ruby
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
557
|
+
## Configuration
|
558
|
+
|
559
|
+
### Session Storage Options
|
139
560
|
|
140
|
-
|
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
|
-
|
577
|
+
def set(key, value)
|
578
|
+
# Your storage logic
|
579
|
+
end
|
580
|
+
end
|
143
581
|
|
144
|
-
|
582
|
+
config.use_session_store MySessionStore
|
583
|
+
```
|
145
584
|
|
146
585
|
## Development
|
147
586
|
|
148
|
-
|
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
|
-
|
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
|
-
##
|
612
|
+
## Roadmap
|
153
613
|
|
154
|
-
|
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
|
-
|
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 "
|
2
|
+
require "rake/testtask"
|
3
3
|
|
4
|
-
|
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: :
|
10
|
+
task default: :test
|
data/lib/flow_chat/config.rb
CHANGED
@@ -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
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
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
|
-
|
13
|
-
|
14
|
-
|
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
|
data/lib/flow_chat/context.rb
CHANGED
@@ -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
|
data/lib/flow_chat/interrupt.rb
CHANGED
@@ -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
|
@@ -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
|
19
|
+
context.input = params["USERDATA"].presence
|
20
20
|
|
21
21
|
type, prompt, choices = @app.call(context)
|
22
22
|
|
@@ -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
|
-
|
12
|
-
session
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
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
|
43
|
-
return unless
|
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.
|
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).
|
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
|
data/lib/flow_chat/version.rb
CHANGED
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.
|
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:
|
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
|
-
- ".
|
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.
|
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