conduit-ussd 0.1.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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +426 -0
- data/Rakefile +2 -0
- data/app/assets/stylesheets/conduit/application.css +15 -0
- data/app/controllers/conduit/application_controller.rb +4 -0
- data/app/helpers/conduit/application_helper.rb +4 -0
- data/app/jobs/conduit/application_job.rb +4 -0
- data/app/jobs/conduit/save_session_job.rb +11 -0
- data/app/models/conduit/application_record.rb +5 -0
- data/app/models/conduit/session_record.rb +28 -0
- data/config/routes.rb +2 -0
- data/lib/conduit/configuration.rb +25 -0
- data/lib/conduit/display_builder.rb +58 -0
- data/lib/conduit/engine.rb +21 -0
- data/lib/conduit/flow.rb +54 -0
- data/lib/conduit/middleware/logging.rb +23 -0
- data/lib/conduit/middleware/session_tracking.rb +30 -0
- data/lib/conduit/middleware/throttling.rb +41 -0
- data/lib/conduit/middleware.rb +36 -0
- data/lib/conduit/providers/africas_talking.rb +39 -0
- data/lib/conduit/request_handler.rb +126 -0
- data/lib/conduit/response.rb +23 -0
- data/lib/conduit/router.rb +30 -0
- data/lib/conduit/session.rb +73 -0
- data/lib/conduit/session_store.rb +41 -0
- data/lib/conduit/state.rb +189 -0
- data/lib/conduit/validator.rb +55 -0
- data/lib/conduit/version.rb +3 -0
- data/lib/conduit.rb +31 -0
- data/lib/generators/conduit/install/install_generator.rb +41 -0
- data/lib/generators/conduit/install/templates/conduit.rb +26 -0
- data/lib/generators/conduit/install/templates/example_flow.rb +72 -0
- data/lib/generators/conduit/install/templates/ussd_controller.rb +11 -0
- data/lib/generators/conduit/migration/migration_generator.rb +21 -0
- data/lib/generators/conduit/migration/templates/create_conduit_sessions.rb +19 -0
- data/lib/tasks/conduit_tasks.rake +4 -0
- metadata +191 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 557f1741896d47caed701bf3bb0c73f19dc128dd0cdf5cd076699baebaf8435b
|
|
4
|
+
data.tar.gz: dd048e06d6d0cac802f7ad3be66ad62ad0fa20730838bbc2c317281da7a5eb91
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: fa64e78847e2ec50787f91d8bcc27a3cca8c81d2cac82b8dcd361b9bfebc556092a20d67898676f4b2bc35cc78bc4aa4f4c1927f329df8af20ac0a8622669867
|
|
7
|
+
data.tar.gz: 2c1ea4bffd6af908fbf71581a0f5100c56ecc372a48cb5516e783af31b0f195eb8d585072184b8488abd1132abe69eea8e7ed69fe61be79998c467fd9595f086
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright charles chuck
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
# Conduit
|
|
2
|
+
|
|
3
|
+
Lightning-fast USSD flow engine for Rails applications. Build interactive USSD services with an expressive DSL, Redis-backed session management, and built-in support for AfricasTalking.
|
|
4
|
+
|
|
5
|
+
## Design Philosophy
|
|
6
|
+
|
|
7
|
+
Conduit is built on four core principles that make USSD development in Africa a joy:
|
|
8
|
+
|
|
9
|
+
### 1. **Speed is Everything**
|
|
10
|
+
USSD sessions have a strict 60-120 second timeout. Every millisecond counts. Conduit uses Redis connection pooling, minimal middleware overhead, and optimized session serialization to deliver sub-50ms response times.
|
|
11
|
+
|
|
12
|
+
### 2. **Developer Experience First**
|
|
13
|
+
Your flow code should read like a specification. Conduit's DSL is designed to be self-documenting, making it easy for teams to understand, maintain, and extend USSD applications.
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
state :welcome do
|
|
17
|
+
display "Welcome! What would you like to do?"
|
|
18
|
+
on "1", to: :check_balance
|
|
19
|
+
on "2", to: :send_money
|
|
20
|
+
end
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### 3. **African Mobile Networks are Unique**
|
|
24
|
+
Built specifically for African telecom infrastructure. Handles network delays, session drops, and the quirks of different USSD gateways. First-class support for AfricasTalking with extensible provider architecture.
|
|
25
|
+
|
|
26
|
+
### 4. **Production-Grade from Day One**
|
|
27
|
+
Includes middleware for logging, throttling, and session tracking. Built-in error handling, session persistence, and monitoring hooks. Deploy with confidence.
|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
- **Blazing Fast** - Redis-backed sessions with sub-50ms response times
|
|
32
|
+
- **Expressive DSL** - Write flows that read like specifications
|
|
33
|
+
- **AfricasTalking Ready** - Built-in provider support with extensible architecture
|
|
34
|
+
- **Smart Navigation** - Automatic back (0) and home (00) handling with navigation stack
|
|
35
|
+
- **Production Ready** - Middleware, throttling, error handling, and session persistence
|
|
36
|
+
- **Multi-language** - Easy internationalization support
|
|
37
|
+
- **60-120s Sessions** - Optimized for USSD's time constraints
|
|
38
|
+
- **Rails Integration** - Seamless Rails engine with generators and conventions
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
### Installation
|
|
43
|
+
|
|
44
|
+
Add to your Gemfile:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
gem "conduit-ussd"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Then run:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
bundle install
|
|
54
|
+
rails generate conduit:install
|
|
55
|
+
rails generate conduit:migration
|
|
56
|
+
rails db:migrate
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Your First Flow
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
# app/flows/banking_flow.rb
|
|
63
|
+
class BankingFlow < Conduit::Flow
|
|
64
|
+
initial_state :welcome
|
|
65
|
+
|
|
66
|
+
state :welcome do
|
|
67
|
+
display <<~TEXT
|
|
68
|
+
Welcome to MobileBank
|
|
69
|
+
|
|
70
|
+
1. Check Balance
|
|
71
|
+
2. Send Money
|
|
72
|
+
3. Buy Airtime
|
|
73
|
+
TEXT
|
|
74
|
+
|
|
75
|
+
on "1", to: :check_balance
|
|
76
|
+
on "2", to: :send_money
|
|
77
|
+
on "3", to: :buy_airtime
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
state :check_balance do
|
|
81
|
+
display do |session|
|
|
82
|
+
balance = User.find_by(phone: session.msisdn)&.balance || 0
|
|
83
|
+
"Your balance is KES #{balance}"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
state :send_money do
|
|
88
|
+
display "Enter phone number:"
|
|
89
|
+
|
|
90
|
+
on_any do |input, session|
|
|
91
|
+
if valid_phone?(input)
|
|
92
|
+
session.data[:recipient] = input
|
|
93
|
+
session.navigate_to(:enter_amount)
|
|
94
|
+
else
|
|
95
|
+
Conduit::Response.new(text: "Invalid phone number. Try again:")
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
state :enter_amount do
|
|
101
|
+
display do |session|
|
|
102
|
+
"Send money to #{session.data[:recipient]}\nEnter amount:"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
on_any do |input, session|
|
|
106
|
+
amount = input.to_i
|
|
107
|
+
if amount > 0 && amount <= user_balance(session)
|
|
108
|
+
process_transfer(session, amount)
|
|
109
|
+
Conduit::Response.new(text: "Money sent successfully!", action: :end)
|
|
110
|
+
else
|
|
111
|
+
Conduit::Response.new(text: "Invalid amount. Try again:")
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def valid_phone?(phone)
|
|
119
|
+
phone.match?(/^254\d{9}$/)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def user_balance(session)
|
|
123
|
+
User.find_by(phone: session.msisdn)&.balance || 0
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def process_transfer(session, amount)
|
|
127
|
+
# Your transfer logic here
|
|
128
|
+
TransferService.new(
|
|
129
|
+
from: session.msisdn,
|
|
130
|
+
to: session.data[:recipient],
|
|
131
|
+
amount: amount
|
|
132
|
+
).call
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Configuration
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
# config/initializers/conduit.rb
|
|
141
|
+
Conduit.configure do |config|
|
|
142
|
+
config.redis_url = ENV.fetch("REDIS_URL", "redis://localhost:6379/1")
|
|
143
|
+
config.session_ttl = 90.seconds
|
|
144
|
+
config.max_navigation_depth = 10
|
|
145
|
+
|
|
146
|
+
# Middleware (order matters)
|
|
147
|
+
config.middleware.use Conduit::Middleware::Logging
|
|
148
|
+
config.middleware.use Conduit::Middleware::Throttling, max_requests: 20, window: 60
|
|
149
|
+
config.middleware.use Conduit::Middleware::SessionTracking
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Route service codes to flows
|
|
153
|
+
Conduit::Router.draw do
|
|
154
|
+
route "*123#", to: BankingFlow
|
|
155
|
+
route "*456#", to: AirtimeFlow
|
|
156
|
+
route "*789#", to: UtilitiesFlow
|
|
157
|
+
end
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Controller
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
# app/controllers/ussd_controller.rb
|
|
164
|
+
class UssdController < ApplicationController
|
|
165
|
+
def handle
|
|
166
|
+
handler = Conduit::RequestHandler.new(
|
|
167
|
+
Conduit::Providers::AfricasTalking.new(params)
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
response = handler.process
|
|
171
|
+
render plain: response
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Advanced Features
|
|
177
|
+
|
|
178
|
+
### Session Management
|
|
179
|
+
|
|
180
|
+
```ruby
|
|
181
|
+
state :collect_info do
|
|
182
|
+
display "Enter your name:"
|
|
183
|
+
|
|
184
|
+
on_any do |input, session|
|
|
185
|
+
session.data[:user_name] = input
|
|
186
|
+
session.data[:collected_at] = Time.current
|
|
187
|
+
session.navigate_to(:confirm)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
state :confirm do
|
|
192
|
+
display do |session|
|
|
193
|
+
"Hello #{session.data[:user_name]}!
|
|
194
|
+
Session started: #{session.started_at}
|
|
195
|
+
Duration: #{session.duration.to_i}s"
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Navigation Stack
|
|
201
|
+
|
|
202
|
+
```ruby
|
|
203
|
+
# Automatic back/home handling
|
|
204
|
+
state :menu do
|
|
205
|
+
display <<~TEXT
|
|
206
|
+
Main Menu
|
|
207
|
+
1. Services
|
|
208
|
+
2. Account
|
|
209
|
+
|
|
210
|
+
0. Back
|
|
211
|
+
00. Home
|
|
212
|
+
TEXT
|
|
213
|
+
|
|
214
|
+
# 0 and 00 are handled automatically
|
|
215
|
+
on "1", to: :services
|
|
216
|
+
on "2", to: :account
|
|
217
|
+
end
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### Conditional Flows
|
|
221
|
+
|
|
222
|
+
```ruby
|
|
223
|
+
state :check_eligibility do
|
|
224
|
+
display "Checking your eligibility..."
|
|
225
|
+
|
|
226
|
+
transition do |session|
|
|
227
|
+
user = User.find_by(phone: session.msisdn)
|
|
228
|
+
|
|
229
|
+
if user&.kyc_verified?
|
|
230
|
+
:premium_services
|
|
231
|
+
elsif user&.basic_verified?
|
|
232
|
+
:basic_services
|
|
233
|
+
else
|
|
234
|
+
:verification_required
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### Error Handling
|
|
241
|
+
|
|
242
|
+
```ruby
|
|
243
|
+
state :risky_operation do
|
|
244
|
+
display "Processing..."
|
|
245
|
+
|
|
246
|
+
on_any do |input, session|
|
|
247
|
+
begin
|
|
248
|
+
result = ExternalService.call(input)
|
|
249
|
+
session.navigate_to(:success)
|
|
250
|
+
rescue ExternalService::Error => e
|
|
251
|
+
Conduit.logger.error("Service failed: #{e.message}")
|
|
252
|
+
Conduit::Response.new(text: "Service temporarily unavailable. Try again later.", action: :end)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### Custom Middleware
|
|
259
|
+
|
|
260
|
+
```ruby
|
|
261
|
+
class AuthenticationMiddleware < Conduit::Middleware::Base
|
|
262
|
+
def call(env)
|
|
263
|
+
session = env[:session]
|
|
264
|
+
|
|
265
|
+
unless authenticated?(session.msisdn)
|
|
266
|
+
return Conduit::Response.end("Please register first by dialing *100#")
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
@app.call(env)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
private
|
|
273
|
+
|
|
274
|
+
def authenticated?(phone)
|
|
275
|
+
User.exists?(phone: phone, status: 'active')
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Add to config
|
|
280
|
+
config.middleware.use AuthenticationMiddleware
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
## Testing
|
|
284
|
+
|
|
285
|
+
```ruby
|
|
286
|
+
# spec/flows/banking_flow_spec.rb
|
|
287
|
+
RSpec.describe BankingFlow do
|
|
288
|
+
let(:session) { build_session(msisdn: "254712345678") }
|
|
289
|
+
let(:flow) { described_class.new }
|
|
290
|
+
|
|
291
|
+
describe "welcome state" do
|
|
292
|
+
it "displays menu options" do
|
|
293
|
+
response = flow.process(session)
|
|
294
|
+
|
|
295
|
+
expect(response.text).to include("Welcome to MobileBank")
|
|
296
|
+
expect(response.text).to include("1. Check Balance")
|
|
297
|
+
expect(response).to be_continue
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
describe "balance inquiry" do
|
|
302
|
+
before { session.current_state = :welcome }
|
|
303
|
+
|
|
304
|
+
it "shows balance when option 1 is selected" do
|
|
305
|
+
create(:user, phone: "254712345678", balance: 1500)
|
|
306
|
+
|
|
307
|
+
response = flow.process(session, "1")
|
|
308
|
+
|
|
309
|
+
expect(response.text).to include("Your balance is KES 1500")
|
|
310
|
+
expect(response).to be_end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
## Deployment
|
|
317
|
+
|
|
318
|
+
### Production Configuration
|
|
319
|
+
|
|
320
|
+
```ruby
|
|
321
|
+
# config/environments/production.rb
|
|
322
|
+
config.cache_store = :redis_cache_store, {
|
|
323
|
+
url: ENV['REDIS_URL'],
|
|
324
|
+
pool_size: 20,
|
|
325
|
+
pool_timeout: 0.1
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
# Use separate Redis instance for Conduit sessions
|
|
329
|
+
Conduit.configure do |config|
|
|
330
|
+
config.redis_url = ENV.fetch("CONDUIT_REDIS_URL", ENV["REDIS_URL"])
|
|
331
|
+
config.redis_pool_size = 20
|
|
332
|
+
config.session_ttl = 120.seconds
|
|
333
|
+
end
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
### Monitoring
|
|
337
|
+
|
|
338
|
+
```ruby
|
|
339
|
+
# config/initializers/conduit.rb
|
|
340
|
+
class MetricsMiddleware < Conduit::Middleware::Base
|
|
341
|
+
def call(env)
|
|
342
|
+
start_time = Time.current
|
|
343
|
+
|
|
344
|
+
result = @app.call(env)
|
|
345
|
+
|
|
346
|
+
duration = (Time.current - start_time) * 1000
|
|
347
|
+
Rails.logger.info("USSD Response time: #{duration.round(2)}ms")
|
|
348
|
+
|
|
349
|
+
# Send to your monitoring service
|
|
350
|
+
StatsD.histogram('ussd.response_time', duration)
|
|
351
|
+
|
|
352
|
+
result
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
config.middleware.use MetricsMiddleware
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
## Provider Support
|
|
360
|
+
|
|
361
|
+
Currently supports AfricasTalking with extensible provider architecture:
|
|
362
|
+
|
|
363
|
+
```ruby
|
|
364
|
+
# Custom provider
|
|
365
|
+
class CustomProvider
|
|
366
|
+
def initialize(params)
|
|
367
|
+
@params = params
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def parse_request
|
|
371
|
+
{
|
|
372
|
+
session_id: @params[:session_id],
|
|
373
|
+
msisdn: @params[:phone_number],
|
|
374
|
+
service_code: @params[:service_code],
|
|
375
|
+
input: @params[:user_input]
|
|
376
|
+
}
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def format_response(response)
|
|
380
|
+
{
|
|
381
|
+
message: response.text,
|
|
382
|
+
action: response.end? ? 'terminate' : 'continue'
|
|
383
|
+
}.to_json
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
## Architecture
|
|
389
|
+
|
|
390
|
+
Conduit follows a middleware-based architecture:
|
|
391
|
+
|
|
392
|
+
```
|
|
393
|
+
Request → Provider → Middleware Chain → Router → Flow → Response → Provider → Response
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
- **Provider**: Parses telecom-specific requests
|
|
397
|
+
- **Middleware**: Cross-cutting concerns (logging, throttling, auth)
|
|
398
|
+
- **Router**: Maps service codes to flows
|
|
399
|
+
- **Flow**: Your business logic
|
|
400
|
+
- **Session**: Redis-backed state management
|
|
401
|
+
|
|
402
|
+
## Contributing
|
|
403
|
+
|
|
404
|
+
1. Fork the repository
|
|
405
|
+
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
|
|
406
|
+
3. Write tests for your changes
|
|
407
|
+
4. Ensure all tests pass (`bundle exec rspec`)
|
|
408
|
+
5. Run the linter (`bundle exec rubocop`)
|
|
409
|
+
6. Commit your changes (`git commit -m 'Add amazing feature'`)
|
|
410
|
+
7. Push to the branch (`git push origin feature/amazing-feature`)
|
|
411
|
+
8. Open a Pull Request
|
|
412
|
+
|
|
413
|
+
## License
|
|
414
|
+
|
|
415
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
416
|
+
|
|
417
|
+
## Support
|
|
418
|
+
|
|
419
|
+
- 📧 Email: support@conduit-ussd.com
|
|
420
|
+
- 🐛 Issues: [GitHub Issues](https://github.com/chalchuck/conduit/issues)
|
|
421
|
+
- 📖 Docs: [Full Documentation](https://docs.conduit-ussd.com)
|
|
422
|
+
- 💬 Community: [Discord](https://discord.gg/conduit-ussd)
|
|
423
|
+
|
|
424
|
+
---
|
|
425
|
+
|
|
426
|
+
Built with ❤️ for African developers by African developers.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This is a manifest file that'll be compiled into application.css, which will include all the files
|
|
3
|
+
* listed below.
|
|
4
|
+
*
|
|
5
|
+
* Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
|
|
6
|
+
* or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
|
|
7
|
+
*
|
|
8
|
+
* You're free to add application-wide styles to this file and they'll appear at the bottom of the
|
|
9
|
+
* compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
|
|
10
|
+
* files in this directory. Styles in this file should be added after the last require_* statement.
|
|
11
|
+
* It is generally better to create a new file per style scope.
|
|
12
|
+
*
|
|
13
|
+
*= require_tree .
|
|
14
|
+
*= require_self
|
|
15
|
+
*/
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Conduit
|
|
2
|
+
class SessionRecord < ApplicationRecord
|
|
3
|
+
self.table_name = "conduit_sessions"
|
|
4
|
+
|
|
5
|
+
validates :session_id, presence: true, uniqueness: true
|
|
6
|
+
validates :msisdn, presence: true
|
|
7
|
+
validates :started_at, presence: true
|
|
8
|
+
|
|
9
|
+
scope :completed, -> { where(completed: true) }
|
|
10
|
+
scope :incomplete, -> { where(completed: false) }
|
|
11
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
12
|
+
scope :for_phone, ->(msisdn) { where(msisdn:) }
|
|
13
|
+
|
|
14
|
+
def self.from_session(session, completed: false)
|
|
15
|
+
new(
|
|
16
|
+
session_id: session.session_id,
|
|
17
|
+
msisdn: session.msisdn,
|
|
18
|
+
service_code: session.service_code,
|
|
19
|
+
final_state: session.current_state,
|
|
20
|
+
data: session.data,
|
|
21
|
+
duration_seconds: session.duration.to_i,
|
|
22
|
+
completed:,
|
|
23
|
+
started_at: session.started_at,
|
|
24
|
+
completed_at: completed ? Time.current : nil
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# lib/conduit/configuration.rb
|
|
2
|
+
module Conduit
|
|
3
|
+
class Configuration
|
|
4
|
+
attr_accessor :session_ttl, :redis_url, :max_navigation_depth,
|
|
5
|
+
:redis_pool_size, :logger, :save_sessions
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@session_ttl = 90.seconds
|
|
9
|
+
@redis_url = "redis://localhost:6379/1"
|
|
10
|
+
@max_navigation_depth = 10
|
|
11
|
+
@redis_pool_size = 10 # this number is experimental+configurable
|
|
12
|
+
@logger = Rails.logger
|
|
13
|
+
@save_sessions = true
|
|
14
|
+
@middleware = ::Conduit::Middleware::Chain.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
attr_reader :middleware
|
|
18
|
+
|
|
19
|
+
def redis_pool
|
|
20
|
+
@redis_pool ||= ConnectionPool.new(size: redis_pool_size, timeout: 0.1) do
|
|
21
|
+
Redis.new(url: redis_url, driver: :hiredis)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module Conduit
|
|
2
|
+
class DisplayBuilder
|
|
3
|
+
attr_reader :parts
|
|
4
|
+
|
|
5
|
+
def initialize
|
|
6
|
+
@parts = []
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def header(*lines)
|
|
10
|
+
@parts << lines.join("\n")
|
|
11
|
+
@parts << "" # Add blank line after header
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def text(content)
|
|
15
|
+
@parts << content
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def menu(&block)
|
|
19
|
+
menu_builder = MenuBuilder.new
|
|
20
|
+
menu_builder.instance_eval(&block)
|
|
21
|
+
@parts << menu_builder.to_s
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def blank_line
|
|
25
|
+
@parts << ""
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_s
|
|
29
|
+
@parts.join("\n")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class MenuBuilder
|
|
34
|
+
def initialize
|
|
35
|
+
@options = []
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def option(number, text)
|
|
39
|
+
@options << "#{number}. #{text}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def back_option(text = "Back")
|
|
43
|
+
@options << "0. #{text}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def home_option(text = "Main Menu")
|
|
47
|
+
@options << "00. #{text}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def exit_option(text = "Exit")
|
|
51
|
+
@options << "000. #{text}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def to_s
|
|
55
|
+
@options.join("\n")
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
require "redis"
|
|
2
|
+
require "connection_pool"
|
|
3
|
+
|
|
4
|
+
module Conduit
|
|
5
|
+
class Engine < ::Rails::Engine
|
|
6
|
+
isolate_namespace Conduit
|
|
7
|
+
|
|
8
|
+
config.generators do |generator|
|
|
9
|
+
generator.test_framework :rspec
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
config.autoload_paths << File.expand_path("../", __dir__)
|
|
13
|
+
|
|
14
|
+
initializer "conduit.setup" do
|
|
15
|
+
Conduit.configure do |config|
|
|
16
|
+
config.session_ttl ||= 90.seconds
|
|
17
|
+
config.redis_url ||= ENV.fetch("REDIS_URL", "redis://localhost:6379/1")
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
data/lib/conduit/flow.rb
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module Conduit
|
|
2
|
+
class Flow
|
|
3
|
+
class InvalidTransition < StandardError; end
|
|
4
|
+
|
|
5
|
+
class << self
|
|
6
|
+
attr_reader :initial_state_name
|
|
7
|
+
|
|
8
|
+
def states
|
|
9
|
+
@states ||= {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initial_state(name)
|
|
13
|
+
@initial_state_name = name
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def state(name, options = {}, &)
|
|
17
|
+
states[name.to_sym] = State.new(name, options, &)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize
|
|
22
|
+
@states = self.class.states
|
|
23
|
+
@initial_state = self.class.initial_state_name
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def process(session, input = nil)
|
|
27
|
+
# Initialize state if needed
|
|
28
|
+
if session.current_state.nil? || session.current_state == "initial"
|
|
29
|
+
session.current_state = @initial_state
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Get current state object
|
|
33
|
+
current_state = @states[session.current_state.to_sym]
|
|
34
|
+
|
|
35
|
+
unless current_state
|
|
36
|
+
raise InvalidTransition, "State '#{session.current_state}' not found"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Process input if provided
|
|
40
|
+
if input.present?
|
|
41
|
+
result = current_state.handle_input(input, session, self)
|
|
42
|
+
return result if result.is_a?(Response)
|
|
43
|
+
|
|
44
|
+
# If state changed, render new state
|
|
45
|
+
if session.current_state != current_state.name
|
|
46
|
+
current_state = @states[session.current_state.to_sym]
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Render current state
|
|
51
|
+
current_state.render(session)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|