flow_chat 0.8.1 → 0.9.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/.cliff.toml +74 -0
- data/.github/workflows/ci.yml +2 -3
- data/.github/workflows/release.yml +56 -0
- data/.standard.yml +4 -0
- data/CHANGELOG.md +22 -0
- data/CLAUDE.md +327 -0
- data/CONTRIBUTING.md +134 -0
- data/Gemfile +1 -0
- data/README.md +290 -105
- data/Rakefile +5 -1
- data/SECURITY.md +42 -349
- data/docs/architecture.md +510 -0
- data/docs/async-background-processing.md +298 -0
- data/docs/configuration.md +556 -226
- data/docs/factory-pattern.md +355 -0
- data/docs/gateway-context-variables.md +171 -0
- data/docs/gateway-development.md +723 -0
- data/docs/getting-started.md +429 -0
- data/docs/instrumentation.md +264 -153
- data/docs/platforms/telegram.md +1013 -0
- data/docs/platforms/ussd.md +693 -0
- data/docs/platforms/whatsapp.md +1395 -0
- data/docs/testing.md +243 -365
- data/examples/custom_session_id_example.rb +119 -0
- data/examples/http_controller.rb +154 -0
- data/examples/intercom_configuration_example.rb +118 -0
- data/examples/intercom_controller.rb +194 -0
- data/examples/multi_tenant_whatsapp_controller.rb +4 -4
- data/examples/simulator_controller.rb +21 -1
- data/examples/ussd_controller.rb +10 -10
- data/examples/whatsapp_controller.rb +2 -2
- data/flow_chat.gemspec +4 -0
- data/lib/flow_chat/{base_app.rb → app.rb} +28 -9
- data/lib/flow_chat/async_job.rb +176 -0
- data/lib/flow_chat/config.rb +23 -28
- data/lib/flow_chat/{base_executor.rb → executor.rb} +7 -12
- data/lib/flow_chat/factory.rb +94 -0
- data/lib/flow_chat/gateway_async_support.rb +106 -0
- data/lib/flow_chat/generic_async_job.rb +30 -0
- data/lib/flow_chat/http/gateway/simple.rb +125 -0
- data/lib/flow_chat/http/renderer.rb +41 -0
- data/lib/flow_chat/instrumentation/setup.rb +1 -1
- data/lib/flow_chat/instrumentation.rb +25 -0
- data/lib/flow_chat/intercom/client.rb +155 -0
- data/lib/flow_chat/intercom/configuration.rb +149 -0
- data/lib/flow_chat/intercom/gateway/intercom_api.rb +396 -0
- data/lib/flow_chat/intercom/renderer.rb +71 -0
- data/lib/flow_chat/phone_number_util.rb +49 -0
- data/lib/flow_chat/processor.rb +188 -0
- data/lib/flow_chat/renderers/markdown_support.rb +58 -0
- data/lib/flow_chat/session/cache_session_store.rb +1 -17
- data/lib/flow_chat/session/middleware.rb +43 -26
- data/lib/flow_chat/simulator/controller.rb +17 -5
- data/lib/flow_chat/simulator/views/simulator.html.erb +220 -8
- data/lib/flow_chat/telegram/client.rb +240 -0
- data/lib/flow_chat/telegram/configuration.rb +118 -0
- data/lib/flow_chat/telegram/gateway/bot_api.rb +300 -0
- data/lib/flow_chat/telegram/middleware/choice_mapper.rb +35 -0
- data/lib/flow_chat/telegram/renderer.rb +125 -0
- data/lib/flow_chat/telegram.rb +7 -0
- data/lib/flow_chat/ussd/gateway/nalo.rb +27 -11
- data/lib/flow_chat/ussd/middleware/pagination.rb +9 -5
- data/lib/flow_chat/ussd/renderer.rb +1 -1
- data/lib/flow_chat/version.rb +1 -1
- data/lib/flow_chat/whatsapp/client.rb +144 -13
- data/lib/flow_chat/whatsapp/configuration.rb +1 -1
- data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +144 -106
- data/lib/flow_chat/whatsapp/id_generator.rb +124 -0
- data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +147 -0
- data/lib/flow_chat/whatsapp/renderer.rb +145 -11
- data/lib/flow_chat.rb +11 -1
- data/lib/tasks/release.rake +165 -0
- metadata +88 -21
- data/docs/flows.md +0 -320
- data/docs/images/simulator.png +0 -0
- data/docs/media.md +0 -153
- data/docs/sessions.md +0 -433
- data/docs/ussd-setup.md +0 -322
- data/docs/whatsapp-setup.md +0 -162
- data/examples/whatsapp_message_job.rb +0 -113
- data/lib/flow_chat/base_processor.rb +0 -145
- data/lib/flow_chat/session/rails_session_store.rb +0 -68
- data/lib/flow_chat/ussd/app.rb +0 -6
- data/lib/flow_chat/ussd/gateway/nsano.rb +0 -98
- data/lib/flow_chat/ussd/middleware/executor.rb +0 -24
- data/lib/flow_chat/ussd/processor.rb +0 -39
- data/lib/flow_chat/whatsapp/app.rb +0 -29
- data/lib/flow_chat/whatsapp/middleware/executor.rb +0 -24
- data/lib/flow_chat/whatsapp/processor.rb +0 -32
- data/lib/flow_chat/whatsapp/send_job_support.rb +0 -79
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
# USSD Development with FlowChat
|
|
2
|
+
|
|
3
|
+
USSD (Unstructured Supplementary Service Data) enables interactive menu-driven applications accessible from any mobile phone. FlowChat provides comprehensive USSD support with automatic pagination, choice mapping, and session management.
|
|
4
|
+
|
|
5
|
+
## Supported USSD Gateways
|
|
6
|
+
|
|
7
|
+
FlowChat's pluggable architecture supports multiple USSD gateways:
|
|
8
|
+
|
|
9
|
+
| Gateway | Provider | Status | Features |
|
|
10
|
+
|---------|----------|---------|----------|
|
|
11
|
+
| **Nalo** | Nalo USSD Platform | ✅ Active | Pagination, choice mapping, session management |
|
|
12
|
+
| **Custom** | Your Implementation | 🔧 Build Your Own | Full gateway interface support |
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
# Built-in Nalo gateway (included with FlowChat)
|
|
16
|
+
config.use_gateway FlowChat::Ussd::Gateway::Nalo
|
|
17
|
+
|
|
18
|
+
# Example custom gateway (you would build this)
|
|
19
|
+
config.use_gateway YourCompany::Ussd::Gateway::MTN, mtn_config
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quick Start
|
|
23
|
+
|
|
24
|
+
### Basic USSD Controller
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
class UssdController < ApplicationController
|
|
28
|
+
skip_forgery_protection
|
|
29
|
+
|
|
30
|
+
def process_request
|
|
31
|
+
processor = FlowChat::Processor.new(self) do |config|
|
|
32
|
+
config.use_gateway FlowChat::Ussd::Gateway::Nalo
|
|
33
|
+
config.use_session_store FlowChat::Session::CacheSessionStore
|
|
34
|
+
|
|
35
|
+
# USSD-optimized session configuration
|
|
36
|
+
config.use_session_config(
|
|
37
|
+
boundaries: [:flow, :platform], # Isolate by flow and platform
|
|
38
|
+
identifier: :msisdn # Use phone number for sessions
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
processor.run MenuFlow, :main_menu
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Simple USSD Flow
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
class MenuFlow < FlowChat::Flow
|
|
51
|
+
def main_menu
|
|
52
|
+
choice = app.screen(:menu) do |prompt|
|
|
53
|
+
prompt.select "Welcome! Choose:", {
|
|
54
|
+
"1" => "Account Balance",
|
|
55
|
+
"2" => "Transfer Money",
|
|
56
|
+
"3" => "Buy Airtime",
|
|
57
|
+
"4" => "Contact Support"
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
case choice
|
|
62
|
+
when "1" then show_balance
|
|
63
|
+
when "2" then transfer_money
|
|
64
|
+
when "3" then buy_airtime
|
|
65
|
+
when "4" then show_support
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def show_balance
|
|
72
|
+
balance = get_account_balance(app.msisdn)
|
|
73
|
+
app.say "Balance: UGX #{balance}\nThank you!"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def transfer_money
|
|
77
|
+
recipient = app.screen(:recipient) do |prompt|
|
|
78
|
+
prompt.ask "Enter recipient number:",
|
|
79
|
+
validate: ->(input) {
|
|
80
|
+
return "Invalid number" unless input.match?(/^0\d{9}$/)
|
|
81
|
+
nil
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
amount = app.screen(:amount) do |prompt|
|
|
86
|
+
prompt.ask "Enter amount:",
|
|
87
|
+
validate: ->(input) {
|
|
88
|
+
amt = input.to_i
|
|
89
|
+
return "Minimum UGX 500" if amt < 500
|
|
90
|
+
return "Maximum UGX 1,000,000" if amt > 1_000_000
|
|
91
|
+
nil
|
|
92
|
+
},
|
|
93
|
+
transform: ->(input) { input.to_i }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
confirmed = app.screen(:confirm) do |prompt|
|
|
97
|
+
prompt.yes? "Send UGX #{amount} to #{recipient}?"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
if confirmed
|
|
101
|
+
transfer_id = process_transfer(app.msisdn, recipient, amount)
|
|
102
|
+
app.say "Transfer successful!\nID: #{transfer_id}"
|
|
103
|
+
else
|
|
104
|
+
app.say "Transfer cancelled"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## USSD-Specific Features
|
|
111
|
+
|
|
112
|
+
### Automatic Pagination
|
|
113
|
+
|
|
114
|
+
Long messages are automatically split into pages with navigation options:
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
# Configuration
|
|
118
|
+
FlowChat::Config.ussd.pagination_page_size = 160 # Characters per page
|
|
119
|
+
FlowChat::Config.ussd.pagination_next_option = "#" # Next page option
|
|
120
|
+
FlowChat::Config.ussd.pagination_back_option = "0" # Previous page option
|
|
121
|
+
|
|
122
|
+
# Long content automatically paginated
|
|
123
|
+
def show_transaction_history
|
|
124
|
+
transactions = get_user_transactions(app.msisdn)
|
|
125
|
+
|
|
126
|
+
history = transactions.map.with_index(1) do |txn, i|
|
|
127
|
+
"#{i}. #{txn.date} - #{txn.type}\nAmount: #{txn.amount}\nRef: #{txn.reference}"
|
|
128
|
+
end.join("\n\n")
|
|
129
|
+
|
|
130
|
+
# FlowChat automatically paginates this long content
|
|
131
|
+
app.say "Transaction History:\n\n#{history}"
|
|
132
|
+
end
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Choice Mapping
|
|
136
|
+
|
|
137
|
+
USSD choices are automatically mapped between user selections and actual values:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
def product_selection
|
|
141
|
+
# User sees numbered options, your code gets meaningful values
|
|
142
|
+
product = app.screen(:product) do |prompt|
|
|
143
|
+
prompt.select "Choose product:", {
|
|
144
|
+
"airtime" => "Buy Airtime",
|
|
145
|
+
"data" => "Buy Data Bundle",
|
|
146
|
+
"voice" => "Voice Bundle",
|
|
147
|
+
"sms" => "SMS Bundle"
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# product variable contains "airtime", "data", etc.
|
|
152
|
+
case product
|
|
153
|
+
when "airtime" then buy_airtime_flow
|
|
154
|
+
when "data" then buy_data_flow
|
|
155
|
+
# ...
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Session Management
|
|
161
|
+
|
|
162
|
+
USSD sessions are typically short-lived but can be configured for different behaviors:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
# Ephemeral sessions (default for USSD)
|
|
166
|
+
config.use_session_config(identifier: :request_id)
|
|
167
|
+
|
|
168
|
+
# Durable sessions (survive USSD timeouts)
|
|
169
|
+
config.use_durable_sessions # Uses :msisdn identifier
|
|
170
|
+
|
|
171
|
+
# Custom session configuration
|
|
172
|
+
config.use_session_config(
|
|
173
|
+
boundaries: [:flow, :platform, :gateway], # Session isolation
|
|
174
|
+
identifier: :msisdn, # Use phone number
|
|
175
|
+
hash_identifiers: true # Hash for privacy
|
|
176
|
+
)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Advanced USSD Patterns
|
|
180
|
+
|
|
181
|
+
### Multi-Step Validation
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
def registration_flow
|
|
185
|
+
# Phone number validation with formatting
|
|
186
|
+
phone = app.screen(:phone) do |prompt|
|
|
187
|
+
prompt.ask "Enter phone number:",
|
|
188
|
+
validate: ->(input) {
|
|
189
|
+
# Remove common prefixes/formatting
|
|
190
|
+
clean = input.gsub(/[\s\-\(\)]/, '')
|
|
191
|
+
clean = clean.sub(/^(\+256|256|0)/, '')
|
|
192
|
+
|
|
193
|
+
return "Invalid format" unless clean.match?(/^\d{9}$/)
|
|
194
|
+
nil
|
|
195
|
+
},
|
|
196
|
+
transform: ->(input) {
|
|
197
|
+
clean = input.gsub(/[\s\-\(\)]/, '').sub(/^(\+256|256|0)/, '')
|
|
198
|
+
"+256#{clean}"
|
|
199
|
+
}
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Name validation
|
|
203
|
+
name = app.screen(:name) do |prompt|
|
|
204
|
+
prompt.ask "Enter full name:",
|
|
205
|
+
validate: ->(input) {
|
|
206
|
+
return "Name too short" if input.length < 2
|
|
207
|
+
return "Name too long" if input.length > 50
|
|
208
|
+
return "Invalid characters" unless input.match?(/^[a-zA-Z\s\.]+$/)
|
|
209
|
+
nil
|
|
210
|
+
},
|
|
211
|
+
transform: ->(input) { input.strip.titleize }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Create account
|
|
215
|
+
account_id = create_account(phone, name)
|
|
216
|
+
app.say "Welcome #{name}!\nAccount: #{account_id}"
|
|
217
|
+
end
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### Conditional Menu Systems
|
|
221
|
+
|
|
222
|
+
```ruby
|
|
223
|
+
def main_menu
|
|
224
|
+
user = get_user_profile(app.msisdn)
|
|
225
|
+
|
|
226
|
+
if user.nil?
|
|
227
|
+
registration_flow
|
|
228
|
+
elsif user.account_status == 'suspended'
|
|
229
|
+
suspended_account_menu
|
|
230
|
+
elsif user.account_type == 'premium'
|
|
231
|
+
premium_menu
|
|
232
|
+
else
|
|
233
|
+
standard_menu
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def premium_menu
|
|
238
|
+
choice = app.screen(:premium_menu) do |prompt|
|
|
239
|
+
prompt.select "Premium Menu:", {
|
|
240
|
+
"1" => "💳 Account Balance",
|
|
241
|
+
"2" => "💸 Transfer Money",
|
|
242
|
+
"3" => "📱 Buy Bundles",
|
|
243
|
+
"4" => "📊 Investment Dashboard",
|
|
244
|
+
"5" => "🏆 Premium Support",
|
|
245
|
+
"0" => "Exit"
|
|
246
|
+
}
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Handle premium-specific options
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def standard_menu
|
|
253
|
+
choice = app.screen(:standard_menu) do |prompt|
|
|
254
|
+
prompt.select "Main Menu:", {
|
|
255
|
+
"1" => "Check Balance",
|
|
256
|
+
"2" => "Transfer Money",
|
|
257
|
+
"3" => "Buy Airtime",
|
|
258
|
+
"4" => "Support",
|
|
259
|
+
"0" => "Exit"
|
|
260
|
+
}
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
### Error Handling and Recovery
|
|
266
|
+
|
|
267
|
+
```ruby
|
|
268
|
+
def transfer_money
|
|
269
|
+
begin
|
|
270
|
+
recipient = app.screen(:recipient) do |prompt|
|
|
271
|
+
prompt.ask "Recipient number:",
|
|
272
|
+
validate: method(:validate_phone_number)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
amount = app.screen(:amount) do |prompt|
|
|
276
|
+
prompt.ask "Amount (UGX):",
|
|
277
|
+
validate: method(:validate_amount),
|
|
278
|
+
transform: ->(input) { input.to_i }
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Check balance before confirmation
|
|
282
|
+
balance = get_balance(app.msisdn)
|
|
283
|
+
if amount > balance
|
|
284
|
+
app.say "Insufficient funds.\nBalance: UGX #{balance}\nRequired: UGX #{amount}"
|
|
285
|
+
return
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
confirmed = app.screen(:confirm) do |prompt|
|
|
289
|
+
prompt.yes? "Send UGX #{amount} to #{recipient}?\nFee: UGX #{calculate_fee(amount)}"
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
if confirmed
|
|
293
|
+
transfer_id = process_transfer(app.msisdn, recipient, amount)
|
|
294
|
+
app.say "Transfer successful!\nRef: #{transfer_id}\nNew balance: UGX #{get_balance(app.msisdn)}"
|
|
295
|
+
else
|
|
296
|
+
app.say "Transfer cancelled"
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
rescue InsufficientFundsError => e
|
|
300
|
+
app.say "Transfer failed: Insufficient funds"
|
|
301
|
+
rescue NetworkError => e
|
|
302
|
+
app.say "Network error. Please try again.\nRef: #{SecureRandom.hex(4)}"
|
|
303
|
+
rescue => e
|
|
304
|
+
Rails.logger.error "Transfer error: #{e.message}"
|
|
305
|
+
app.say "Service temporarily unavailable.\nPlease try again later."
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
private
|
|
310
|
+
|
|
311
|
+
def validate_phone_number(input)
|
|
312
|
+
# Normalize input
|
|
313
|
+
clean = input.gsub(/[\s\-\(\)]/, '')
|
|
314
|
+
clean = clean.sub(/^(\+256|256|0)/, '')
|
|
315
|
+
|
|
316
|
+
return "Enter 9 digits" unless clean.match?(/^\d{9}$/)
|
|
317
|
+
return "Cannot send to yourself" if "+256#{clean}" == app.msisdn
|
|
318
|
+
nil
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def validate_amount(input)
|
|
322
|
+
amount = input.to_i
|
|
323
|
+
return "Enter numbers only" if amount == 0 && input != "0"
|
|
324
|
+
return "Minimum UGX 500" if amount < 500
|
|
325
|
+
return "Maximum UGX 2,000,000" if amount > 2_000_000
|
|
326
|
+
nil
|
|
327
|
+
end
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
### Back Navigation
|
|
331
|
+
|
|
332
|
+
```ruby
|
|
333
|
+
def multi_level_menu
|
|
334
|
+
main_choice = app.screen(:main) do |prompt|
|
|
335
|
+
prompt.select "Main Menu:", {
|
|
336
|
+
"1" => "Financial Services",
|
|
337
|
+
"2" => "Lifestyle Services",
|
|
338
|
+
"3" => "Account Settings",
|
|
339
|
+
"0" => "Exit"
|
|
340
|
+
}
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
case main_choice
|
|
344
|
+
when "1"
|
|
345
|
+
financial_services_menu
|
|
346
|
+
when "2"
|
|
347
|
+
lifestyle_services_menu
|
|
348
|
+
when "3"
|
|
349
|
+
account_settings_menu
|
|
350
|
+
when "0"
|
|
351
|
+
app.say "Thank you for using our service!"
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def financial_services_menu
|
|
356
|
+
choice = app.screen(:financial) do |prompt|
|
|
357
|
+
prompt.select "Financial Services:", {
|
|
358
|
+
"1" => "Transfer Money",
|
|
359
|
+
"2" => "Pay Bills",
|
|
360
|
+
"3" => "Savings Account",
|
|
361
|
+
"9" => "Back to Main Menu",
|
|
362
|
+
"0" => "Exit"
|
|
363
|
+
}
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
case choice
|
|
367
|
+
when "1" then transfer_money
|
|
368
|
+
when "2" then pay_bills
|
|
369
|
+
when "3" then savings_menu
|
|
370
|
+
when "9"
|
|
371
|
+
# Clear current screen and go back
|
|
372
|
+
app.session.delete(:financial)
|
|
373
|
+
multi_level_menu
|
|
374
|
+
when "0"
|
|
375
|
+
app.say "Thank you!"
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Alternative: Use app.go_back for automatic navigation
|
|
380
|
+
def with_go_back
|
|
381
|
+
if user_wants_to_go_back?
|
|
382
|
+
app.go_back # Automatically handles navigation stack
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
## USSD Configuration
|
|
388
|
+
|
|
389
|
+
### Global USSD Settings
|
|
390
|
+
|
|
391
|
+
```ruby
|
|
392
|
+
# config/initializers/flow_chat.rb
|
|
393
|
+
FlowChat::Config.ussd.pagination_page_size = 140 # SMS character limit
|
|
394
|
+
FlowChat::Config.ussd.pagination_next_option = "#" # Next page key
|
|
395
|
+
FlowChat::Config.ussd.pagination_back_option = "0" # Previous page key
|
|
396
|
+
FlowChat::Config.ussd.pagination_next_text = "More" # Next page text
|
|
397
|
+
FlowChat::Config.ussd.pagination_back_text = "Back" # Previous page text
|
|
398
|
+
|
|
399
|
+
# Validation settings
|
|
400
|
+
FlowChat::Config.combine_validation_error_with_message = true # Show original + error
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
### Per-Request Configuration
|
|
404
|
+
|
|
405
|
+
```ruby
|
|
406
|
+
def process_request
|
|
407
|
+
# Configure pagination based on network
|
|
408
|
+
network = detect_network(request)
|
|
409
|
+
|
|
410
|
+
case network
|
|
411
|
+
when :mtn
|
|
412
|
+
FlowChat::Config.ussd.pagination_page_size = 160
|
|
413
|
+
when :airtel
|
|
414
|
+
FlowChat::Config.ussd.pagination_page_size = 140
|
|
415
|
+
when :africel
|
|
416
|
+
FlowChat::Config.ussd.pagination_page_size = 120
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
processor = FlowChat::Processor.new(self) do |config|
|
|
420
|
+
config.use_gateway FlowChat::Ussd::Gateway::Nalo
|
|
421
|
+
config.use_session_store FlowChat::Session::CacheSessionStore
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
processor.run MenuFlow, :main_menu
|
|
425
|
+
end
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
## Testing USSD Applications
|
|
429
|
+
|
|
430
|
+
### Using the Simulator
|
|
431
|
+
|
|
432
|
+
```ruby
|
|
433
|
+
# In rails console or test
|
|
434
|
+
simulator = FlowChat::Simulator.new(MenuFlow, :main_menu)
|
|
435
|
+
|
|
436
|
+
# Simulate USSD session
|
|
437
|
+
simulator.start
|
|
438
|
+
# => "Welcome! Choose:\n1. Account Balance\n2. Transfer Money\n..."
|
|
439
|
+
|
|
440
|
+
simulator.send_message("2") # Select transfer money
|
|
441
|
+
# => "Enter recipient number:"
|
|
442
|
+
|
|
443
|
+
simulator.send_message("0701234567")
|
|
444
|
+
# => "Enter amount:"
|
|
445
|
+
|
|
446
|
+
simulator.send_message("5000")
|
|
447
|
+
# => "Send UGX 5000 to 0701234567?\n1. Yes\n2. No"
|
|
448
|
+
|
|
449
|
+
simulator.send_message("1") # Confirm
|
|
450
|
+
# => "Transfer successful!\nRef: TXN123456"
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
### Integration Testing
|
|
454
|
+
|
|
455
|
+
```ruby
|
|
456
|
+
# test/integration/ussd_flow_test.rb
|
|
457
|
+
class UssdFlowTest < ActionDispatch::IntegrationTest
|
|
458
|
+
def test_complete_transfer_flow
|
|
459
|
+
# Simulate Nalo USSD webhook
|
|
460
|
+
post ussd_path, params: {
|
|
461
|
+
msisdn: "256701234567",
|
|
462
|
+
text: "",
|
|
463
|
+
session_id: "test_session_123"
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
assert_response :success
|
|
467
|
+
assert_includes response.body, "Welcome! Choose:"
|
|
468
|
+
|
|
469
|
+
# Select transfer money
|
|
470
|
+
post ussd_path, params: {
|
|
471
|
+
msisdn: "256701234567",
|
|
472
|
+
text: "2",
|
|
473
|
+
session_id: "test_session_123"
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
assert_includes response.body, "Enter recipient number:"
|
|
477
|
+
|
|
478
|
+
# Continue flow...
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
## Network-Specific Considerations
|
|
484
|
+
|
|
485
|
+
### Character Limits
|
|
486
|
+
|
|
487
|
+
Different networks have different USSD character limits:
|
|
488
|
+
|
|
489
|
+
```ruby
|
|
490
|
+
NETWORK_LIMITS = {
|
|
491
|
+
mtn: 160,
|
|
492
|
+
airtel: 140,
|
|
493
|
+
africel: 120,
|
|
494
|
+
utl: 140
|
|
495
|
+
}.freeze
|
|
496
|
+
|
|
497
|
+
def adjust_pagination_for_network(network)
|
|
498
|
+
limit = NETWORK_LIMITS[network] || 140
|
|
499
|
+
FlowChat::Config.ussd.pagination_page_size = limit
|
|
500
|
+
end
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
### Network-Specific Features
|
|
504
|
+
|
|
505
|
+
```ruby
|
|
506
|
+
def network_aware_menu
|
|
507
|
+
network = detect_network(app.msisdn)
|
|
508
|
+
|
|
509
|
+
base_options = {
|
|
510
|
+
"1" => "Check Balance",
|
|
511
|
+
"2" => "Transfer Money",
|
|
512
|
+
"3" => "Buy Airtime"
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
# Add network-specific options
|
|
516
|
+
case network
|
|
517
|
+
when :mtn
|
|
518
|
+
base_options["4"] = "MTN Mobile Money"
|
|
519
|
+
base_options["5"] = "MTN Packages"
|
|
520
|
+
when :airtel
|
|
521
|
+
base_options["4"] = "Airtel Money"
|
|
522
|
+
base_options["5"] = "Airtel Packages"
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
choice = app.screen(:network_menu) do |prompt|
|
|
526
|
+
prompt.select "Services:", base_options
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
handle_choice(choice, network)
|
|
530
|
+
end
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
## Performance Optimization
|
|
534
|
+
|
|
535
|
+
### Efficient Database Queries
|
|
536
|
+
|
|
537
|
+
```ruby
|
|
538
|
+
def show_transaction_history
|
|
539
|
+
# Efficient pagination for large datasets
|
|
540
|
+
page_size = 5
|
|
541
|
+
offset = (current_page - 1) * page_size
|
|
542
|
+
|
|
543
|
+
transactions = Transaction
|
|
544
|
+
.where(msisdn: app.msisdn)
|
|
545
|
+
.order(created_at: :desc)
|
|
546
|
+
.limit(page_size)
|
|
547
|
+
.offset(offset)
|
|
548
|
+
.pluck(:date, :type, :amount, :reference)
|
|
549
|
+
|
|
550
|
+
if transactions.empty?
|
|
551
|
+
app.say "No transactions found"
|
|
552
|
+
return
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
history = transactions.map.with_index(offset + 1) do |(date, type, amount, ref), i|
|
|
556
|
+
"#{i}. #{date.strftime('%d/%m')} #{type}\nUGX #{amount}\n#{ref}"
|
|
557
|
+
end.join("\n\n")
|
|
558
|
+
|
|
559
|
+
app.say "Transactions:\n#{history}"
|
|
560
|
+
end
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
### Caching Strategies
|
|
564
|
+
|
|
565
|
+
```ruby
|
|
566
|
+
def get_exchange_rates
|
|
567
|
+
# Cache exchange rates for 1 hour
|
|
568
|
+
Rails.cache.fetch("exchange_rates", expires_in: 1.hour) do
|
|
569
|
+
fetch_exchange_rates_from_api
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def get_user_profile(msisdn)
|
|
574
|
+
# Cache user profile for session duration
|
|
575
|
+
app.session.get("user_profile") || begin
|
|
576
|
+
profile = User.find_by(msisdn: msisdn)
|
|
577
|
+
app.session.set("user_profile", profile)
|
|
578
|
+
profile
|
|
579
|
+
end
|
|
580
|
+
end
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
## Security Best Practices
|
|
584
|
+
|
|
585
|
+
### Input Sanitization
|
|
586
|
+
|
|
587
|
+
```ruby
|
|
588
|
+
def secure_input_handling
|
|
589
|
+
# Always validate and sanitize input
|
|
590
|
+
amount = app.screen(:amount) do |prompt|
|
|
591
|
+
prompt.ask "Enter amount:",
|
|
592
|
+
validate: ->(input) {
|
|
593
|
+
# Remove any non-numeric characters
|
|
594
|
+
clean = input.gsub(/[^\d]/, '')
|
|
595
|
+
return "Enter numbers only" if clean.empty?
|
|
596
|
+
|
|
597
|
+
amount = clean.to_i
|
|
598
|
+
return "Amount too small" if amount < 100
|
|
599
|
+
return "Amount too large" if amount > 10_000_000
|
|
600
|
+
nil
|
|
601
|
+
},
|
|
602
|
+
transform: ->(input) { input.gsub(/[^\d]/, '').to_i }
|
|
603
|
+
end
|
|
604
|
+
end
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
### Session Security
|
|
608
|
+
|
|
609
|
+
```ruby
|
|
610
|
+
# Hash sensitive identifiers
|
|
611
|
+
config.use_session_config(
|
|
612
|
+
identifier: :msisdn,
|
|
613
|
+
hash_identifiers: true # Phone numbers are hashed
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
# Implement session timeouts
|
|
617
|
+
class SecureSessionStore < FlowChat::Session::CacheSessionStore
|
|
618
|
+
def get(key)
|
|
619
|
+
data = super
|
|
620
|
+
if data && session_expired?
|
|
621
|
+
delete_all_session_data
|
|
622
|
+
return nil
|
|
623
|
+
end
|
|
624
|
+
data
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
private
|
|
628
|
+
|
|
629
|
+
def session_expired?
|
|
630
|
+
last_activity = @context.session.get("last_activity")
|
|
631
|
+
return true unless last_activity
|
|
632
|
+
|
|
633
|
+
Time.current - Time.parse(last_activity) > 10.minutes
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
## Troubleshooting
|
|
639
|
+
|
|
640
|
+
### Common USSD Issues
|
|
641
|
+
|
|
642
|
+
1. **Session Loss**
|
|
643
|
+
```ruby
|
|
644
|
+
# Use durable sessions for longer flows
|
|
645
|
+
config.use_durable_sessions
|
|
646
|
+
|
|
647
|
+
# Or implement session recovery
|
|
648
|
+
def recover_session
|
|
649
|
+
if app.session.get(:current_screen).nil?
|
|
650
|
+
# Restart from main menu
|
|
651
|
+
main_menu
|
|
652
|
+
end
|
|
653
|
+
end
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
2. **Character Encoding**
|
|
657
|
+
```ruby
|
|
658
|
+
# Ensure proper encoding for special characters
|
|
659
|
+
def safe_message(text)
|
|
660
|
+
text.encode('UTF-8', invalid: :replace, undef: :replace)
|
|
661
|
+
end
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
3. **Timeout Handling**
|
|
665
|
+
```ruby
|
|
666
|
+
def handle_timeout
|
|
667
|
+
app.say "Session timeout.\nDial *123# to continue"
|
|
668
|
+
rescue FlowChat::Interrupt::SessionTimeout
|
|
669
|
+
handle_timeout
|
|
670
|
+
end
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
### Debugging
|
|
674
|
+
|
|
675
|
+
Enable detailed logging:
|
|
676
|
+
|
|
677
|
+
```ruby
|
|
678
|
+
# config/initializers/flow_chat.rb
|
|
679
|
+
FlowChat::Config.logger.level = Logger::DEBUG
|
|
680
|
+
|
|
681
|
+
# In your flow
|
|
682
|
+
Rails.logger.debug "USSD Debug - Screen: #{app.navigation_stack.last}"
|
|
683
|
+
Rails.logger.debug "USSD Debug - Input: #{app.input.inspect}"
|
|
684
|
+
Rails.logger.debug "USSD Debug - Session: #{app.session.inspect}"
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
## Next Steps
|
|
688
|
+
|
|
689
|
+
- **[Gateway Development](../gateway-development.md)** - Build custom USSD gateways
|
|
690
|
+
- **[Session Management](../session-management.md)** - Advanced session configuration
|
|
691
|
+
- **[Multi-Platform Development](multi-platform.md)** - Share flows across platforms
|
|
692
|
+
|
|
693
|
+
See the complete USSD documentation for advanced patterns, configuration options, and best practices.
|