flow_chat 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +29 -0
  3. data/.github/workflows/pages.yml +43 -0
  4. data/.gitignore +4 -1
  5. data/CHANGELOG.md +49 -0
  6. data/README.md +166 -295
  7. data/Rakefile +12 -1
  8. data/SECURITY.md +1 -1
  9. data/docs/architecture.md +52 -479
  10. data/docs/async-background-processing.md +31 -265
  11. data/docs/configuration.md +106 -613
  12. data/docs/factory-pattern.md +27 -324
  13. data/docs/gateway-context-variables.md +140 -143
  14. data/docs/gateway-development.md +86 -650
  15. data/docs/getting-started.md +40 -379
  16. data/docs/instrumentation.md +88 -279
  17. data/docs/platforms/instagram.md +278 -0
  18. data/docs/platforms/messenger.md +205 -0
  19. data/docs/platforms/telegram.md +47 -951
  20. data/docs/platforms/ussd.md +38 -653
  21. data/docs/platforms/whatsapp.md +73 -1321
  22. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md +732 -0
  23. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json +58 -0
  24. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md +4064 -0
  25. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json +226 -0
  26. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md +972 -0
  27. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json +88 -0
  28. data/docs/superpowers/specs/2026-07-09-inbound-media-support-design.md +195 -0
  29. data/docs/superpowers/specs/2026-08-10-messenger-instagram-design.md +391 -0
  30. data/docs/testing.md +26 -297
  31. data/examples/http_controller.rb +12 -10
  32. data/examples/intercom_configuration_example.rb +19 -24
  33. data/examples/intercom_controller.rb +8 -20
  34. data/examples/multi_tenant_whatsapp_controller.rb +61 -166
  35. data/examples/simulator_controller.rb +0 -1
  36. data/examples/ussd_controller.rb +86 -158
  37. data/examples/whatsapp_controller.rb +16 -15
  38. data/examples/whatsapp_media_examples.rb +27 -79
  39. data/flow_chat.gemspec +22 -2
  40. data/gemfiles/rails_main.gemfile +18 -0
  41. data/lib/flow_chat/app.rb +129 -11
  42. data/lib/flow_chat/async_job.rb +10 -0
  43. data/lib/flow_chat/choice_titles.rb +95 -0
  44. data/lib/flow_chat/config.rb +124 -1
  45. data/lib/flow_chat/delivery_error.rb +9 -0
  46. data/lib/flow_chat/gateway_async_support.rb +19 -1
  47. data/lib/flow_chat/http/configuration_error.rb +9 -0
  48. data/lib/flow_chat/http/gateway/simple.rb +24 -4
  49. data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
  50. data/lib/flow_chat/input.rb +86 -0
  51. data/lib/flow_chat/instagram/client.rb +32 -0
  52. data/lib/flow_chat/instagram/configuration.rb +147 -0
  53. data/lib/flow_chat/instagram/configuration_error.rb +7 -0
  54. data/lib/flow_chat/instagram/gateway/send_api.rb +63 -0
  55. data/lib/flow_chat/instagram/middleware/choice_mapper.rb +22 -0
  56. data/lib/flow_chat/instagram/renderer.rb +23 -0
  57. data/lib/flow_chat/instrumentation/metrics_collector.rb +6 -1
  58. data/lib/flow_chat/instrumentation.rb +160 -1
  59. data/lib/flow_chat/intercom/client.rb +34 -28
  60. data/lib/flow_chat/intercom/configuration.rb +2 -49
  61. data/lib/flow_chat/intercom/configuration_error.rb +9 -0
  62. data/lib/flow_chat/intercom/gateway/intercom_api.rb +81 -57
  63. data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
  64. data/lib/flow_chat/intercom/renderer.rb +57 -5
  65. data/lib/flow_chat/media.rb +121 -0
  66. data/lib/flow_chat/messenger/client.rb +264 -0
  67. data/lib/flow_chat/messenger/configuration.rb +103 -0
  68. data/lib/flow_chat/messenger/configuration_error.rb +9 -0
  69. data/lib/flow_chat/messenger/gateway/send_api.rb +42 -0
  70. data/lib/flow_chat/messenger/middleware/choice_mapper.rb +185 -0
  71. data/lib/flow_chat/messenger/renderer.rb +150 -0
  72. data/lib/flow_chat/meta/challenge.rb +24 -0
  73. data/lib/flow_chat/meta/choice_ladder.rb +37 -0
  74. data/lib/flow_chat/meta/configuration_error.rb +7 -0
  75. data/lib/flow_chat/meta/gateway_identity.rb +38 -0
  76. data/lib/flow_chat/meta/messaging_gateway.rb +471 -0
  77. data/lib/flow_chat/meta/signature.rb +30 -0
  78. data/lib/flow_chat/meta/signature_validation.rb +66 -0
  79. data/lib/flow_chat/meta/webhook_verification.rb +43 -0
  80. data/lib/flow_chat/named_configuration.rb +65 -0
  81. data/lib/flow_chat/prompt.rb +13 -16
  82. data/lib/flow_chat/renderers/markdown_support.rb +109 -0
  83. data/lib/flow_chat/security.rb +76 -0
  84. data/lib/flow_chat/session/middleware.rb +11 -2
  85. data/lib/flow_chat/simulator/controller.rb +31 -15
  86. data/lib/flow_chat/simulator/views/simulator.html.erb +184 -20
  87. data/lib/flow_chat/telegram/client.rb +47 -4
  88. data/lib/flow_chat/telegram/configuration.rb +2 -42
  89. data/lib/flow_chat/telegram/configuration_error.rb +9 -0
  90. data/lib/flow_chat/telegram/gateway/bot_api.rb +46 -28
  91. data/lib/flow_chat/telegram/middleware/choice_mapper.rb +77 -16
  92. data/lib/flow_chat/telegram/renderer.rb +10 -2
  93. data/lib/flow_chat/text_truncator.rb +75 -0
  94. data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
  95. data/lib/flow_chat/version.rb +1 -1
  96. data/lib/flow_chat/whatsapp/client.rb +14 -7
  97. data/lib/flow_chat/whatsapp/configuration.rb +12 -51
  98. data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
  99. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +259 -203
  100. data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +144 -57
  101. data/lib/flow_chat/whatsapp/renderer.rb +121 -60
  102. data/lib/flow_chat.rb +0 -10
  103. data/lib/tasks/release.rake +11 -1
  104. data/site/.nojekyll +0 -0
  105. data/site/.og-card.html +89 -0
  106. data/site/favicon.svg +6 -0
  107. data/site/index.html +209 -0
  108. data/site/og.png +0 -0
  109. metadata +60 -11
  110. data/lib/flow_chat/whatsapp/id_generator.rb +0 -124
@@ -1,693 +1,78 @@
1
- # USSD Development with FlowChat
1
+ # USSD
2
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.
3
+ USSD is the synchronous, text-only channel behind codes like `*123#`. The telco opens a session, sends each user entry to your webhook as a plain POST, and shows whatever text you return. FlowChat drives it with the same flows you write for every other platform, plus USSD-specific pagination and choice numbering.
4
4
 
5
- ## Supported USSD Gateways
5
+ FlowChat ships one USSD gateway: `FlowChat::Ussd::Gateway::Nalo`, for the [Nalo](https://nalosolutions.com) aggregator. Other aggregators are supported by writing a gateway (see [gateway-development.md](../gateway-development.md)).
6
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
7
+ ## Setup
25
8
 
26
9
  ```ruby
10
+ # app/controllers/ussd_controller.rb
27
11
  class UssdController < ApplicationController
28
12
  skip_forgery_protection
29
-
30
- def process_request
13
+
14
+ def webhook
31
15
  processor = FlowChat::Processor.new(self) do |config|
32
16
  config.use_gateway FlowChat::Ussd::Gateway::Nalo
33
17
  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
18
  end
84
19
 
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
- # ...
20
+ processor.run RegistrationFlow, :main_page
156
21
  end
157
22
  end
158
23
  ```
159
24
 
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
25
  ```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
26
+ # config/routes.rb
27
+ post "/ussd", to: "ussd#webhook"
218
28
  ```
219
29
 
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
- ```
30
+ The Nalo gateway reads `USERID`, `MSISDN`, and `USERDATA` from the request and renders a JSON response with `MSG` (the text to show) and `MSGTYPE` (`true` while the session continues, `false` when it ends). The phone number is normalized to E.164 and exposed as `app.msisdn`. Nalo takes no configuration object; the aggregator identifies your service by the endpoint URL, so there are no per-gateway credentials to pass, unlike the WhatsApp and Telegram gateways.
264
31
 
265
- ### Error Handling and Recovery
32
+ ## The flow is the same
266
33
 
267
34
  ```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
35
+ class RegistrationFlow < FlowChat::Flow
36
+ def main_page
37
+ name = app.screen(:name) { |prompt| prompt.ask "What's your name?" }
291
38
 
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"
39
+ email = app.screen(:email) do |prompt|
40
+ prompt.ask "Your email?", validate: ->(input) { "Invalid email" unless input.include?("@") }
297
41
  end
298
42
 
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
43
+ app.say "Welcome #{name}!"
579
44
  end
580
45
  end
581
46
  ```
582
47
 
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
- ```
48
+ ## Choice numbering
606
49
 
607
- ### Session Security
50
+ You define choices by their keys; FlowChat shows the user a numbered list and maps the number they type back to your key before the flow sees it. Your flow always works in keys, never in the displayed numbers.
608
51
 
609
52
  ```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
53
+ choice = app.screen(:menu) do |prompt|
54
+ prompt.select "Main menu:", { "balance" => "Check balance", "airtime" => "Buy airtime" }
635
55
  end
56
+ # The user sees "1. Check balance / 2. Buy airtime" and types 1 or 2.
57
+ # choice is "balance" or "airtime".
636
58
  ```
637
59
 
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
- ```
60
+ ## Pagination
655
61
 
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
- ```
62
+ USSD messages are length-limited. When rendered output exceeds `FlowChat::Config.ussd.pagination_page_size` (140 characters by default), FlowChat splits it into pages at a word boundary and appends navigation options: `#` for "More" and `0` for "Back". It holds the paging state in the session and serves the next or previous page when the user sends the matching option, so a long menu or message spans several turns without any work in your flow. Tune the size and the option labels through [configuration.md](../configuration.md#ussd-configuration).
663
63
 
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
- ```
64
+ ## Limits to keep in mind
686
65
 
687
- ## Next Steps
66
+ | Area | Behavior on USSD |
67
+ |---|---|
68
+ | Message length | Output over 140 characters (default) is paginated into multiple turns. |
69
+ | Media | There is no inline media. Outbound `media:` is degraded to a text line with the media's URL, for example `Image: https://...`. |
70
+ | Rich choices | Choices render as a numbered text list, not buttons. |
71
+ | Async | Not supported. The USSD protocol needs a synchronous response, so `use_async` has no effect here. |
72
+ | Sessions | The telco's session id can rotate on timeout. Use `use_durable_sessions` to key the session on the phone number so a conversation survives a rotation. See [configuration.md](../configuration.md#sessions). |
688
73
 
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
74
+ ## Related
692
75
 
693
- See the complete USSD documentation for advanced patterns, configuration options, and best practices.
76
+ - [Getting started](../getting-started.md)
77
+ - [Configuration](../configuration.md)
78
+ - [Building a gateway](../gateway-development.md)