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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +29 -0
- data/.github/workflows/pages.yml +43 -0
- data/.gitignore +4 -1
- data/CHANGELOG.md +49 -0
- data/README.md +166 -295
- data/Rakefile +12 -1
- data/SECURITY.md +1 -1
- data/docs/architecture.md +52 -479
- data/docs/async-background-processing.md +31 -265
- data/docs/configuration.md +106 -613
- data/docs/factory-pattern.md +27 -324
- data/docs/gateway-context-variables.md +140 -143
- data/docs/gateway-development.md +86 -650
- data/docs/getting-started.md +40 -379
- data/docs/instrumentation.md +88 -279
- data/docs/platforms/instagram.md +278 -0
- data/docs/platforms/messenger.md +205 -0
- data/docs/platforms/telegram.md +47 -951
- data/docs/platforms/ussd.md +38 -653
- data/docs/platforms/whatsapp.md +73 -1321
- data/docs/superpowers/plans/2026-07-09-inbound-media-support.md +732 -0
- data/docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json +58 -0
- data/docs/superpowers/plans/2026-08-10-messenger-instagram.md +4064 -0
- data/docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json +226 -0
- data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md +972 -0
- data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json +88 -0
- data/docs/superpowers/specs/2026-07-09-inbound-media-support-design.md +195 -0
- data/docs/superpowers/specs/2026-08-10-messenger-instagram-design.md +391 -0
- data/docs/testing.md +26 -297
- data/examples/http_controller.rb +12 -10
- data/examples/intercom_configuration_example.rb +19 -24
- data/examples/intercom_controller.rb +8 -20
- data/examples/multi_tenant_whatsapp_controller.rb +61 -166
- data/examples/simulator_controller.rb +0 -1
- data/examples/ussd_controller.rb +86 -158
- data/examples/whatsapp_controller.rb +16 -15
- data/examples/whatsapp_media_examples.rb +27 -79
- data/flow_chat.gemspec +22 -2
- data/gemfiles/rails_main.gemfile +18 -0
- data/lib/flow_chat/app.rb +129 -11
- data/lib/flow_chat/async_job.rb +10 -0
- data/lib/flow_chat/choice_titles.rb +95 -0
- data/lib/flow_chat/config.rb +124 -1
- data/lib/flow_chat/delivery_error.rb +9 -0
- data/lib/flow_chat/gateway_async_support.rb +19 -1
- data/lib/flow_chat/http/configuration_error.rb +9 -0
- data/lib/flow_chat/http/gateway/simple.rb +24 -4
- data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
- data/lib/flow_chat/input.rb +86 -0
- data/lib/flow_chat/instagram/client.rb +32 -0
- data/lib/flow_chat/instagram/configuration.rb +147 -0
- data/lib/flow_chat/instagram/configuration_error.rb +7 -0
- data/lib/flow_chat/instagram/gateway/send_api.rb +63 -0
- data/lib/flow_chat/instagram/middleware/choice_mapper.rb +22 -0
- data/lib/flow_chat/instagram/renderer.rb +23 -0
- data/lib/flow_chat/instrumentation/metrics_collector.rb +6 -1
- data/lib/flow_chat/instrumentation.rb +160 -1
- data/lib/flow_chat/intercom/client.rb +34 -28
- data/lib/flow_chat/intercom/configuration.rb +2 -49
- data/lib/flow_chat/intercom/configuration_error.rb +9 -0
- data/lib/flow_chat/intercom/gateway/intercom_api.rb +81 -57
- data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
- data/lib/flow_chat/intercom/renderer.rb +57 -5
- data/lib/flow_chat/media.rb +121 -0
- data/lib/flow_chat/messenger/client.rb +264 -0
- data/lib/flow_chat/messenger/configuration.rb +103 -0
- data/lib/flow_chat/messenger/configuration_error.rb +9 -0
- data/lib/flow_chat/messenger/gateway/send_api.rb +42 -0
- data/lib/flow_chat/messenger/middleware/choice_mapper.rb +185 -0
- data/lib/flow_chat/messenger/renderer.rb +150 -0
- data/lib/flow_chat/meta/challenge.rb +24 -0
- data/lib/flow_chat/meta/choice_ladder.rb +37 -0
- data/lib/flow_chat/meta/configuration_error.rb +7 -0
- data/lib/flow_chat/meta/gateway_identity.rb +38 -0
- data/lib/flow_chat/meta/messaging_gateway.rb +471 -0
- data/lib/flow_chat/meta/signature.rb +30 -0
- data/lib/flow_chat/meta/signature_validation.rb +66 -0
- data/lib/flow_chat/meta/webhook_verification.rb +43 -0
- data/lib/flow_chat/named_configuration.rb +65 -0
- data/lib/flow_chat/prompt.rb +13 -16
- data/lib/flow_chat/renderers/markdown_support.rb +109 -0
- data/lib/flow_chat/security.rb +76 -0
- data/lib/flow_chat/session/middleware.rb +11 -2
- data/lib/flow_chat/simulator/controller.rb +31 -15
- data/lib/flow_chat/simulator/views/simulator.html.erb +184 -20
- data/lib/flow_chat/telegram/client.rb +47 -4
- data/lib/flow_chat/telegram/configuration.rb +2 -42
- data/lib/flow_chat/telegram/configuration_error.rb +9 -0
- data/lib/flow_chat/telegram/gateway/bot_api.rb +46 -28
- data/lib/flow_chat/telegram/middleware/choice_mapper.rb +77 -16
- data/lib/flow_chat/telegram/renderer.rb +10 -2
- data/lib/flow_chat/text_truncator.rb +75 -0
- data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
- data/lib/flow_chat/version.rb +1 -1
- data/lib/flow_chat/whatsapp/client.rb +14 -7
- data/lib/flow_chat/whatsapp/configuration.rb +12 -51
- data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
- data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +259 -203
- data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +144 -57
- data/lib/flow_chat/whatsapp/renderer.rb +121 -60
- data/lib/flow_chat.rb +0 -10
- data/lib/tasks/release.rake +11 -1
- data/site/.nojekyll +0 -0
- data/site/.og-card.html +89 -0
- data/site/favicon.svg +6 -0
- data/site/index.html +209 -0
- data/site/og.png +0 -0
- metadata +60 -11
- data/lib/flow_chat/whatsapp/id_generator.rb +0 -124
data/docs/platforms/ussd.md
CHANGED
|
@@ -1,693 +1,78 @@
|
|
|
1
|
-
# USSD
|
|
1
|
+
# USSD
|
|
2
2
|
|
|
3
|
-
USSD
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32
|
+
## The flow is the same
|
|
266
33
|
|
|
267
34
|
```ruby
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
611
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
+
- [Getting started](../getting-started.md)
|
|
77
|
+
- [Configuration](../configuration.md)
|
|
78
|
+
- [Building a gateway](../gateway-development.md)
|