flow_chat 0.7.0 → 0.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1d5c2d35096c65d8953ed19fe427a61b13c7b2fabab871cc99562bde861576be
4
- data.tar.gz: ee342523ae86be4397bbb0586a2a38966d473094a1d33aacb047fe26edf93c78
3
+ metadata.gz: 5ebeb5cdcbacce73ec580e381554dbd2377f7896d7d06c488970051f6a8bf599
4
+ data.tar.gz: bc33f9f54c78264f416e00163e2ba0cf78da2e8e0961c942bb3aab1e86e783e5
5
5
  SHA512:
6
- metadata.gz: bae27cb42464181afbc3751082344d9aaa6c8d4ae6915794aa5671fec8622eb24f2d694d14a3414c11f3125c954a022a781ed91eaf4705c0314b240920434eac
7
- data.tar.gz: 50fd0405af1d6e1e79ef62be8bb2e6bca81ed0d46297bf7b1ed5bbd04f0d892b30261fde9c547a523bd9003ad8612efee3aadb11f683af4af0fded396c6a2538
6
+ metadata.gz: 34006bb23ddaf1aefd8ec4876cb70d8f06d2bbb23da48663ff7a40dd630d9b01af58d9980c343c9d933fa637512375b5c7ba3b0b336ba91a314fdd9ae5fb2ba1
7
+ data.tar.gz: 78a242f2b175f57ce29326f342c9d4bf4c7c4402c0e2fcd634c21322280dcd0ac273d86eb19750702472218e9d08fd97267ad711b3673f108f89617e5319b9f7
data/README.md CHANGED
@@ -186,6 +186,7 @@ See the [Testing Guide](docs/testing.md) for complete setup instructions and tes
186
186
  - **[WhatsApp Setup](docs/whatsapp-setup.md)** - Comprehensive WhatsApp configuration
187
187
  - **[USSD Setup](docs/ussd-setup.md)** - USSD gateway configuration and examples
188
188
  - **[Flow Development](docs/flows.md)** - Advanced flow patterns and techniques
189
+ - **[Session Management](docs/sessions.md)** - Session architecture and configuration
189
190
  - **[Media Support](docs/media.md)** - Rich media handling for WhatsApp
190
191
  - **[Testing Guide](docs/testing.md)** - Complete testing strategies
191
192
  - **[Configuration Reference](docs/configuration.md)** - All configuration options
@@ -19,6 +19,26 @@ FlowChat::Config.combine_validation_error_with_message = true # default
19
19
  FlowChat.setup_instrumentation!
20
20
  ```
21
21
 
22
+ ## Session Configuration
23
+
24
+ ```ruby
25
+ # Session boundaries control how session IDs are constructed
26
+ FlowChat::Config.session.boundaries = [:flow, :platform] # default
27
+ FlowChat::Config.session.hash_phone_numbers = true # hash phone numbers for privacy
28
+ FlowChat::Config.session.identifier = nil # let platforms choose (default)
29
+
30
+ # Available boundary options:
31
+ # :flow - separate sessions per flow class
32
+ # :platform - separate sessions per platform (ussd, whatsapp)
33
+ # :gateway - separate sessions per gateway
34
+ # [] - global sessions (no boundaries)
35
+
36
+ # Available identifier options:
37
+ # nil - platform chooses default (:request_id for USSD, :msisdn for WhatsApp)
38
+ # :msisdn - use phone number (durable sessions)
39
+ # :request_id - use request ID (ephemeral sessions)
40
+ ```
41
+
22
42
  ## USSD Configuration
23
43
 
24
44
  ```ruby
@@ -28,10 +48,6 @@ FlowChat::Config.ussd.pagination_next_option = "#" # option to go to next
28
48
  FlowChat::Config.ussd.pagination_next_text = "More" # text for next option
29
49
  FlowChat::Config.ussd.pagination_back_option = "0" # option to go back
30
50
  FlowChat::Config.ussd.pagination_back_text = "Back" # text for back option
31
-
32
- # Resumable sessions
33
- FlowChat::Config.ussd.resumable_sessions_enabled = true # default
34
- FlowChat::Config.ussd.resumable_sessions_timeout_seconds = 300 # 5 minutes
35
51
  ```
36
52
 
37
53
  ## WhatsApp Configuration
@@ -174,8 +190,15 @@ processor = FlowChat::Ussd::Processor.new(self) do |config|
174
190
  # Optional middleware
175
191
  config.use_middleware MyCustomMiddleware
176
192
 
177
- # Optional resumable sessions
178
- config.use_resumable_sessions
193
+ # Configure session boundaries
194
+ config.use_session_config(
195
+ boundaries: [:flow, :platform], # which boundaries to enforce
196
+ hash_phone_numbers: true, # hash phone numbers for privacy
197
+ identifier: :msisdn # use MSISDN for durable sessions (optional)
198
+ )
199
+
200
+ # Shorthand for durable sessions (identifier: :msisdn)
201
+ config.use_durable_sessions
179
202
  end
180
203
  ```
181
204
 
data/docs/sessions.md ADDED
@@ -0,0 +1,433 @@
1
+ # Session Management
2
+
3
+ FlowChat provides a powerful and flexible session management system that enables persistent conversational state across multiple requests. This document covers the architecture, configuration, and best practices for session management.
4
+
5
+ ## Overview
6
+
7
+ Sessions in FlowChat store user conversation state between requests, enabling:
8
+
9
+ - **Multi-step workflows** - Collect information across multiple prompts
10
+ - **Context preservation** - Remember user inputs and conversation history
11
+ - **Cross-platform consistency** - Same session behavior across USSD and WhatsApp
12
+ - **Privacy protection** - Automatic phone number hashing for security
13
+ - **Flexible isolation** - Control session boundaries per deployment needs
14
+
15
+ ## Architecture
16
+
17
+ The session system is built around three core components:
18
+
19
+ ### 1. Session Configuration (`FlowChat::Config::SessionConfig`)
20
+
21
+ Controls how session IDs are generated and sessions are isolated:
22
+
23
+ ```ruby
24
+ # Global session configuration
25
+ FlowChat::Config.session.boundaries = [:flow, :gateway, :platform] # isolation boundaries
26
+ FlowChat::Config.session.hash_phone_numbers = true # privacy protection
27
+ FlowChat::Config.session.identifier = nil # platform chooses default
28
+ ```
29
+
30
+ ### 2. Session Middleware (`FlowChat::Session::Middleware`)
31
+
32
+ Automatically manages session creation and ID generation based on configuration:
33
+
34
+ - Generates consistent session IDs based on boundaries and identifiers
35
+ - Creates session store instances for each request
36
+ - Handles platform-specific defaults (USSD = ephemeral, WhatsApp = durable)
37
+ - Provides instrumentation events for monitoring
38
+
39
+ ### 3. Session Stores
40
+
41
+ Store actual session data with different persistence strategies:
42
+
43
+ - **`FlowChat::Session::CacheSessionStore`** - Uses Rails cache (recommended)
44
+ - **`FlowChat::Session::RailsSessionStore`** - Uses Rails session (limited)
45
+
46
+ ## Session Boundaries
47
+
48
+ Boundaries control how session IDs are constructed, determining when sessions are shared vs. isolated:
49
+
50
+ ### Available Boundaries
51
+
52
+ - **`:flow`** - Separate sessions per flow class
53
+ - **`:platform`** - Separate sessions per platform (ussd, whatsapp)
54
+ - **`:gateway`** - Separate sessions per gateway
55
+ - **`:url`** - Separate sessions per request URL (host + path)
56
+ - **`[]`** - Global sessions (no boundaries)
57
+
58
+ ### Examples
59
+
60
+ ```ruby
61
+ # Default: Full isolation
62
+ FlowChat::Config.session.boundaries = [:flow, :gateway, :platform]
63
+ # Session ID: "registration_flow:nalo:ussd:abc123"
64
+
65
+ # Flow isolation only
66
+ FlowChat::Config.session.boundaries = [:flow]
67
+ # Session ID: "registration_flow:abc123"
68
+
69
+ # Platform isolation only
70
+ FlowChat::Config.session.boundaries = [:platform]
71
+ # Session ID: "ussd:abc123"
72
+
73
+ # Global sessions
74
+ FlowChat::Config.session.boundaries = []
75
+ # Session ID: "abc123"
76
+ ```
77
+
78
+ ## Session Identifiers
79
+
80
+ Identifiers determine what makes a session unique:
81
+
82
+ ### Available Identifiers
83
+
84
+ - **`nil`** - Platform chooses default (recommended)
85
+ - USSD: `:request_id` (ephemeral sessions)
86
+ - WhatsApp: `:msisdn` (durable sessions)
87
+ - **`:msisdn`** - Use phone number (durable sessions)
88
+ - **`:request_id`** - Use request ID (ephemeral sessions)
89
+
90
+ ### Platform Defaults
91
+
92
+ ```ruby
93
+ # USSD: ephemeral sessions by default
94
+ identifier: :request_id
95
+ # New session each time USSD times out
96
+
97
+ # WhatsApp: durable sessions by default
98
+ identifier: :msisdn
99
+ # Same session resumes across conversations
100
+ ```
101
+
102
+ ### Phone Number Hashing
103
+
104
+ When using `:msisdn` identifier, phone numbers are automatically hashed for privacy:
105
+
106
+ ```ruby
107
+ FlowChat::Config.session.hash_phone_numbers = true # default
108
+ # "+256700123456" becomes "a1b2c3d4" (8-character hash)
109
+
110
+ FlowChat::Config.session.hash_phone_numbers = false
111
+ # "+256700123456" used directly (not recommended for production)
112
+ ```
113
+
114
+ ## Configuration Examples
115
+
116
+ ### Basic USSD Configuration
117
+
118
+ ```ruby
119
+ processor = FlowChat::Ussd::Processor.new(self) do |config|
120
+ config.use_gateway FlowChat::Ussd::Gateway::Nalo
121
+ config.use_session_store FlowChat::Session::CacheSessionStore
122
+
123
+ # Use shorthand for standard durable sessions
124
+ config.use_durable_sessions
125
+ end
126
+ ```
127
+
128
+ ### Custom Session Configuration
129
+
130
+ ```ruby
131
+ processor = FlowChat::Ussd::Processor.new(self) do |config|
132
+ config.use_gateway FlowChat::Ussd::Gateway::Nalo
133
+ config.use_session_store FlowChat::Session::CacheSessionStore
134
+
135
+ # Explicit session configuration
136
+ config.use_session_config(
137
+ boundaries: [:flow, :platform], # isolate by flow and platform
138
+ hash_phone_numbers: true, # hash phone numbers for privacy
139
+ identifier: :msisdn # use phone number for durable sessions
140
+ )
141
+ end
142
+ ```
143
+
144
+ ### Cross-gateway Sessions
145
+
146
+ ```ruby
147
+ processor = FlowChat::Ussd::Processor.new(self) do |config|
148
+ config.use_gateway FlowChat::Ussd::Gateway::Nalo
149
+ config.use_session_store FlowChat::Session::CacheSessionStore
150
+
151
+ # Allow sessions to work across different gateways
152
+ config.use_session_config(boundaries: [:flow, :platform])
153
+ end
154
+ ```
155
+
156
+ ### Cross-Platform Sessions
157
+
158
+ ```ruby
159
+ processor = FlowChat::Ussd::Processor.new(self) do |config|
160
+ config.use_gateway FlowChat::Ussd::Gateway::Nalo
161
+ config.use_session_store FlowChat::Session::CacheSessionStore
162
+
163
+ # Allow same user to continue on USSD or WhatsApp
164
+ config.use_cross_platform_sessions
165
+ # Equivalent to: boundaries: [:flow]
166
+ end
167
+ ```
168
+
169
+ ### URL-Based Session Isolation
170
+
171
+ Perfect for multi-tenant applications:
172
+
173
+ ```ruby
174
+ processor = FlowChat::Ussd::Processor.new(self) do |config|
175
+ config.use_gateway FlowChat::Ussd::Gateway::Nalo
176
+ config.use_session_store FlowChat::Session::CacheSessionStore
177
+
178
+ # Isolate sessions by URL (great for multi-tenant SaaS)
179
+ config.use_url_isolation
180
+ # Adds :url to existing boundaries
181
+ end
182
+ ```
183
+
184
+ **URL Boundary Examples:**
185
+ - `tenant1.example.com/ussd` vs `tenant2.example.com/ussd` - Different sessions
186
+ - `api.example.com/v1/ussd` vs `api.example.com/v2/ussd` - Different sessions
187
+ - `dev.example.com/ussd` vs `prod.example.com/ussd` - Different sessions
188
+
189
+ **URL Processing:**
190
+ - Combines host + path: `example.com/api/v1/ussd`
191
+ - Sanitizes special characters: `tenant-1.com/ussd` → `tenant_1.com/ussd`
192
+ - Hashes long URLs (>50 chars): `verylongdomain.../path` → `url_a1b2c3d4`
193
+
194
+ ### Global Sessions
195
+
196
+ ```ruby
197
+ processor = FlowChat::Ussd::Processor.new(self) do |config|
198
+ config.use_gateway FlowChat::Ussd::Gateway::Nalo
199
+ config.use_session_store FlowChat::Session::CacheSessionStore
200
+
201
+ # Single session shared across everything
202
+ config.use_session_config(boundaries: [])
203
+ end
204
+ ```
205
+
206
+ ## Session Stores
207
+
208
+ ### Cache Session Store (Recommended)
209
+
210
+ Uses Rails cache backend with automatic TTL management:
211
+
212
+ ```ruby
213
+ config.use_session_store FlowChat::Session::CacheSessionStore
214
+
215
+ # Requires Rails cache to be configured
216
+ # config/environments/production.rb
217
+ config.cache_store = :redis_cache_store, { url: ENV['REDIS_URL'] }
218
+ ```
219
+
220
+ **Features:**
221
+ - Automatic session expiration via cache TTL
222
+ - Redis/Memcached support for distributed deployments
223
+ - High performance
224
+ - Memory efficient
225
+
226
+ ### Rails Session Store
227
+
228
+ Uses Rails session for storage (limited scope):
229
+
230
+ ```ruby
231
+ config.use_session_store FlowChat::Session::RailsSessionStore
232
+ ```
233
+
234
+ **Limitations:**
235
+ - Tied to HTTP session lifecycle
236
+ - Limited storage capacity
237
+ - Not suitable for long-running conversations
238
+
239
+ ## Session Data Usage
240
+
241
+ ### In Flows
242
+
243
+ Sessions are automatically available in flows:
244
+
245
+ ```ruby
246
+ class RegistrationFlow < FlowChat::Flow
247
+ def main_page
248
+ # Store data
249
+ app.session.set("step", "registration")
250
+ app.session.set("user_data", {name: "John", age: 25})
251
+
252
+ # Retrieve data
253
+ step = app.session.get("step")
254
+ user_data = app.session.get("user_data")
255
+
256
+ # Check existence
257
+ if app.session.get("completed")
258
+ app.say "Registration already completed!"
259
+ return
260
+ end
261
+
262
+ # Continue flow...
263
+ name = app.screen(:name) { |prompt| prompt.ask "Name?" }
264
+ app.session.set("name", name)
265
+ end
266
+ end
267
+ ```
268
+
269
+ ### Session Store API
270
+
271
+ All session stores implement a consistent interface:
272
+
273
+ ```ruby
274
+ # Basic operations
275
+ session.set(key, value) # Store data
276
+ value = session.get(key) # Retrieve data
277
+ session.delete(key) # Delete specific key
278
+ session.clear # Clear all session data
279
+ session.destroy # Destroy entire session
280
+
281
+ # Utility methods
282
+ session.exists? # Check if session has any data
283
+ ```
284
+
285
+ ## Best Practices
286
+
287
+ ### 1. Choose Appropriate Boundaries
288
+
289
+ ```ruby
290
+ # High-traffic public services
291
+ boundaries: [:flow, :gateway, :platform] # Full isolation
292
+
293
+ # Single-tenant applications
294
+ boundaries: [:flow] # Simpler, allows cross-platform
295
+
296
+ # Global state services (rare)
297
+ boundaries: [] # Shared state across everything
298
+ ```
299
+
300
+ ### 2. Consider Session Lifecycle
301
+
302
+ ```ruby
303
+ # USSD: Short sessions, frequent timeouts
304
+ identifier: :request_id # New session each timeout (default)
305
+
306
+ # WhatsApp: Long conversations, persistent
307
+ identifier: :msisdn # Resume across days/weeks (default)
308
+
309
+ # Custom requirements
310
+ identifier: :msisdn # Make USSD durable
311
+ identifier: :request_id # Make WhatsApp ephemeral
312
+ ```
313
+
314
+ ### 3. Handle Session Expiration
315
+
316
+ ```ruby
317
+ class RegistrationFlow < FlowChat::Flow
318
+ def main_page
319
+ # Check if session expired
320
+ if app.session.get("user_id").nil?
321
+ restart_registration
322
+ return
323
+ end
324
+
325
+ # Continue existing session
326
+ continue_registration
327
+ end
328
+
329
+ private
330
+
331
+ def restart_registration
332
+ app.say "Session expired. Let's start over."
333
+ # Reset flow to beginning
334
+ end
335
+ end
336
+ ```
337
+
338
+ ### 4. Optimize Session Data
339
+
340
+ ```ruby
341
+ # Store only necessary data
342
+ app.session.set("user_id", 123) # Good: minimal data
343
+ app.session.set("user", user_object) # Avoid: large objects
344
+
345
+ # Clean up when done
346
+ def complete_registration
347
+ app.session.set("completed", true)
348
+ app.session.delete("temp_data") # Clean up temporary data
349
+ end
350
+ ```
351
+
352
+ ### 5. Security Considerations
353
+
354
+ ```ruby
355
+ # Always hash phone numbers in production
356
+ FlowChat::Config.session.hash_phone_numbers = true
357
+
358
+ # Use secure cache backends
359
+ config.cache_store = :redis_cache_store, {
360
+ url: ENV['REDIS_URL'],
361
+ ssl_params: { verify_mode: OpenSSL::SSL::VERIFY_NONE }
362
+ }
363
+
364
+ # Set appropriate TTLs
365
+ cache_options = { expires_in: 30.minutes } # Reasonable session timeout
366
+ ```
367
+
368
+ ## Troubleshooting
369
+
370
+ ### Session Not Persisting
371
+
372
+ 1. **Check session store configuration:**
373
+ ```ruby
374
+ # Ensure session store is configured
375
+ config.use_session_store FlowChat::Session::CacheSessionStore
376
+ ```
377
+
378
+ 2. **Verify cache backend:**
379
+ ```ruby
380
+ # Test cache is working
381
+ Rails.cache.write("test", "value")
382
+ puts Rails.cache.read("test") # Should output "value"
383
+ ```
384
+
385
+ 3. **Check session boundaries:**
386
+ ```ruby
387
+ # Debug session ID generation
388
+ FlowChat.logger.level = Logger::DEBUG
389
+ # Look for "Session::Middleware: Generated session ID: ..." messages
390
+ ```
391
+
392
+ ### Different Session IDs
393
+
394
+ 1. **Inconsistent request data:**
395
+ - Verify `request.gateway` is consistent
396
+ - Check `request.platform` is set correctly
397
+ - Ensure `request.msisdn` format is consistent
398
+
399
+ 2. **Boundary configuration mismatch:**
400
+ ```ruby
401
+ # Ensure same boundaries across requests
402
+ config.use_session_config(boundaries: [:flow, :platform])
403
+ ```
404
+
405
+ ### Session Data Lost
406
+
407
+ 1. **Cache expiration:**
408
+ ```ruby
409
+ # Increase TTL if needed
410
+ FlowChat::Config.cache = Rails.cache
411
+ # Configure longer expiration in cache store
412
+ ```
413
+
414
+ 2. **Session ID changes:**
415
+ - Check logs for "Generated session ID" messages
416
+ - Verify identifier consistency (`:msisdn` vs `:request_id`)
417
+
418
+ ## Monitoring and Instrumentation
419
+
420
+ FlowChat emits events for session operations:
421
+
422
+ ```ruby
423
+ # Subscribe to session events
424
+ ActiveSupport::Notifications.subscribe("session.created.flow_chat") do |event|
425
+ Rails.logger.info "Session created: #{event.payload[:session_id]}"
426
+ end
427
+
428
+ # Monitor session usage
429
+ ActiveSupport::Notifications.subscribe(/session\..*\.flow_chat/) do |name, start, finish, id, payload|
430
+ # Track session operations for analytics
431
+ Analytics.track("flowchat.session.#{name.split('.')[1]}", payload)
432
+ end
433
+ ```
data/docs/testing.md CHANGED
@@ -43,7 +43,7 @@ class SimulatorController < ApplicationController
43
43
  name: "USSD Integration",
44
44
  icon: "📱",
45
45
  processor_type: "ussd",
46
- provider: "nalo",
46
+ gateway: "nalo",
47
47
  endpoint: "/ussd",
48
48
  color: "#007bff"
49
49
  },
@@ -51,7 +51,7 @@ class SimulatorController < ApplicationController
51
51
  name: "WhatsApp Integration",
52
52
  icon: "💬",
53
53
  processor_type: "whatsapp",
54
- provider: "cloud_api",
54
+ gateway: "cloud_api",
55
55
  endpoint: "/whatsapp/webhook",
56
56
  color: "#25D366"
57
57
  }
data/docs/ussd-setup.md CHANGED
@@ -119,19 +119,35 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor i
119
119
  0 Back
120
120
  ```
121
121
 
122
- ## Resumable Sessions
122
+ ## Session Management
123
123
 
124
- Enable resumable sessions for better user experience:
124
+ Configure session behavior for better user experience:
125
125
 
126
126
  ```ruby
127
127
  processor = FlowChat::Ussd::Processor.new(self) do |config|
128
128
  config.use_gateway FlowChat::Ussd::Gateway::Nalo
129
129
  config.use_session_store FlowChat::Session::CacheSessionStore
130
- config.use_resumable_sessions # Enable resumable sessions
130
+
131
+ # Enable durable sessions (shorthand)
132
+ config.use_durable_sessions
131
133
  end
132
134
  ```
133
135
 
134
- Users can continue interrupted conversations within the timeout period.
136
+ ### Session Boundaries
137
+
138
+ Session boundaries control how session IDs are constructed:
139
+
140
+ - **`:flow`** - Separate sessions per flow class
141
+ - **`:platform`** - Separate USSD from WhatsApp sessions
142
+ - **`:gateway`** - Separate sessions per gateway
143
+ - **`[]`** - Global sessions (no boundaries)
144
+
145
+ Session identifier options:
146
+
147
+ - **`nil`** - Platform chooses default (`:request_id` for USSD, `:msisdn` for WhatsApp)
148
+ - **`:msisdn`** - Use phone number (durable sessions)
149
+ - **`:request_id`** - Use request ID (ephemeral sessions)
150
+ - **`hash_phone_numbers`** - Hash phone numbers for privacy (recommended)
135
151
 
136
152
  ## Middleware
137
153
 
@@ -17,7 +17,7 @@ class SimulatorController < ApplicationController
17
17
  name: "Main USSD Endpoint",
18
18
  description: "Primary USSD integration",
19
19
  processor_type: "ussd",
20
- provider: "nalo",
20
+ gateway: "nalo",
21
21
  endpoint: "/ussd",
22
22
  icon: "📱",
23
23
  color: "#28a745"
@@ -26,7 +26,7 @@ class SimulatorController < ApplicationController
26
26
  name: "Main WhatsApp Endpoint",
27
27
  description: "Primary WhatsApp webhook",
28
28
  processor_type: "whatsapp",
29
- provider: "cloud_api",
29
+ gateway: "cloud_api",
30
30
  endpoint: "/whatsapp/webhook",
31
31
  icon: "💬",
32
32
  color: "#25D366"
@@ -35,7 +35,7 @@ class SimulatorController < ApplicationController
35
35
  name: "Tenant A WhatsApp",
36
36
  description: "Multi-tenant endpoint for Tenant A",
37
37
  processor_type: "whatsapp",
38
- provider: "cloud_api",
38
+ gateway: "cloud_api",
39
39
  endpoint: "/tenants/a/whatsapp/webhook",
40
40
  icon: "🏢",
41
41
  color: "#fd7e14"
@@ -44,7 +44,7 @@ class SimulatorController < ApplicationController
44
44
  name: "Legacy WhatsApp",
45
45
  description: "Legacy endpoint for compatibility",
46
46
  processor_type: "whatsapp",
47
- provider: "cloud_api",
47
+ gateway: "cloud_api",
48
48
  endpoint: "/legacy/whatsapp",
49
49
  icon: "📦",
50
50
  color: "#6c757d"
@@ -10,8 +10,8 @@ class UssdController < ApplicationController
10
10
  # Use Rails session for USSD (shorter sessions)
11
11
  config.use_session_store FlowChat::Session::RailsSessionStore
12
12
 
13
- # Enable resumable sessions (optional)
14
- config.use_resumable_sessions
13
+ # Enable durable sessions (optional)
14
+ config.use_durable_sessions # Configures flow+platform isolation with durable sessions
15
15
  end
16
16
 
17
17
  processor.run WelcomeFlow, :main_page
@@ -232,7 +232,13 @@ class UssdController < ApplicationController
232
232
  config.use_gateway FlowChat::Ussd::Gateway::Nalo
233
233
  config.use_session_store FlowChat::Session::RailsSessionStore
234
234
  config.use_middleware LoggingMiddleware # Add custom logging
235
- config.use_resumable_sessions # Enable resumable sessions
235
+ config.use_durable_sessions # Enable durable sessions
236
+
237
+ # Or configure session boundaries explicitly:
238
+ # config.use_session_config(
239
+ # boundaries: [:flow, :platform], # which boundaries to enforce
240
+ # hash_phone_numbers: true # hash phone numbers for privacy
241
+ # )
236
242
  end
237
243
 
238
244
  processor.run WelcomeFlow, :main_page
@@ -13,6 +13,7 @@ module FlowChat
13
13
  @context["controller"] = controller
14
14
  @context["enable_simulator"] = enable_simulator.nil? ? (defined?(Rails) && Rails.env.local?) : enable_simulator
15
15
  @middleware = ::Middleware::Builder.new(name: middleware_name)
16
+ @session_options = FlowChat::Config.session
16
17
 
17
18
  FlowChat.logger.debug { "BaseProcessor: Simulator mode #{@context["enable_simulator"] ? "enabled" : "disabled"}" }
18
19
 
@@ -29,17 +30,46 @@ module FlowChat
29
30
  end
30
31
 
31
32
  def use_session_store(session_store)
32
- FlowChat.logger.debug { "BaseProcessor: Configuring session store #{session_store.class.name}" }
33
+ raise "Session store must be a class" unless session_store.is_a?(Class)
34
+ FlowChat.logger.debug { "BaseProcessor: Configuring session store #{session_store.name}" }
33
35
  @context["session.store"] = session_store
34
36
  self
35
37
  end
36
38
 
39
+ def use_session_config(boundaries: nil, hash_phone_numbers: nil, identifier: nil)
40
+ FlowChat.logger.debug { "BaseProcessor: Configuring session config: boundaries=#{boundaries.inspect}, hash_phone_numbers=#{hash_phone_numbers}, identifier=#{identifier}" }
41
+
42
+ # Update the session options directly
43
+ @session_options = @session_options.dup
44
+ @session_options.boundaries = Array(boundaries) if boundaries
45
+ @session_options.hash_phone_numbers = hash_phone_numbers if hash_phone_numbers
46
+ @session_options.identifier = identifier if identifier
47
+
48
+ self
49
+ end
50
+
37
51
  def use_middleware(middleware)
38
- FlowChat.logger.debug { "BaseProcessor: Adding middleware #{middleware.class.name}" }
52
+ raise "Middleware must be a class" unless middleware.is_a?(Class)
53
+ FlowChat.logger.debug { "BaseProcessor: Adding middleware #{middleware.name}" }
39
54
  @middleware.use middleware
40
55
  self
41
56
  end
42
57
 
58
+ def use_cross_platform_sessions
59
+ FlowChat.logger.debug { "BaseProcessor: Enabling cross-platform sessions via session configuration" }
60
+ use_session_config(
61
+ boundaries: [:flow]
62
+ )
63
+ self
64
+ end
65
+
66
+ def use_url_isolation
67
+ FlowChat.logger.debug { "BaseProcessor: Enabling URL-based session isolation" }
68
+ current_boundaries = @session_options.boundaries.dup
69
+ current_boundaries << :url unless current_boundaries.include?(:url)
70
+ use_session_config(boundaries: current_boundaries)
71
+ end
72
+
43
73
  def run(flow_class, action)
44
74
  # Instrument flow execution (this will log via LogSubscriber)
45
75
  instrument(Events::FLOW_EXECUTION_START, {
@@ -101,6 +131,7 @@ module FlowChat
101
131
 
102
132
  ::Middleware::Builder.new(name: name) do |b|
103
133
  b.use @gateway_class, *@gateway_args
134
+ b.use FlowChat::Session::Middleware, @session_options
104
135
  configure_middleware_stack(b)
105
136
  end.inject_logger(FlowChat.logger)
106
137
  end
@@ -8,6 +8,11 @@ module FlowChat
8
8
  # When false, only the validation error message is shown to the user.
9
9
  mattr_accessor :combine_validation_error_with_message, default: true
10
10
 
11
+ # Session configuration object
12
+ def self.session
13
+ @session ||= SessionConfig.new
14
+ end
15
+
11
16
  # USSD-specific configuration object
12
17
  def self.ussd
13
18
  @ussd ||= UssdConfig.new
@@ -18,10 +23,29 @@ module FlowChat
18
23
  @whatsapp ||= WhatsappConfig.new
19
24
  end
20
25
 
26
+ class SessionConfig
27
+ attr_accessor :boundaries, :hash_phone_numbers, :identifier
28
+
29
+ def initialize
30
+ # Session boundaries control how session IDs are constructed
31
+ # :flow = separate sessions per flow
32
+ # :gateway = separate sessions per gateway
33
+ # :platform = separate sessions per platform (ussd, whatsapp)
34
+ @boundaries = [:flow, :gateway, :platform]
35
+
36
+ # Always hash phone numbers for privacy
37
+ @hash_phone_numbers = true
38
+
39
+ # Session identifier type (nil = let platforms choose their default)
40
+ # :msisdn = durable sessions (durable across timeouts)
41
+ # :request_id = ephemeral sessions (new session each time)
42
+ @identifier = nil
43
+ end
44
+ end
45
+
21
46
  class UssdConfig
22
47
  attr_accessor :pagination_page_size, :pagination_back_option, :pagination_back_text,
23
- :pagination_next_option, :pagination_next_text,
24
- :resumable_sessions_enabled, :resumable_sessions_global, :resumable_sessions_timeout_seconds
48
+ :pagination_next_option, :pagination_next_text
25
49
 
26
50
  def initialize
27
51
  @pagination_page_size = 140
@@ -29,9 +53,6 @@ module FlowChat
29
53
  @pagination_back_text = "Back"
30
54
  @pagination_next_option = "#"
31
55
  @pagination_next_text = "More"
32
- @resumable_sessions_enabled = false
33
- @resumable_sessions_global = true
34
- @resumable_sessions_timeout_seconds = 300
35
56
  end
36
57
  end
37
58
 
@@ -5,8 +5,9 @@ module FlowChat
5
5
 
6
6
  attr_reader :context
7
7
 
8
- def initialize(app)
8
+ def initialize(app, session_options)
9
9
  @app = app
10
+ @session_options = session_options
10
11
  FlowChat.logger.debug { "Session::Middleware: Initialized session middleware" }
11
12
  end
12
13
 
@@ -19,9 +20,10 @@ module FlowChat
19
20
  context.session = context["session.store"].new(context)
20
21
 
21
22
  # Use instrumentation instead of direct logging for session creation
23
+ store_type = context["session.store"].name || "$Anonymous"
22
24
  instrument(Events::SESSION_CREATED, {
23
25
  session_id: session_id,
24
- store_type: context["session.store"].name,
26
+ store_type: store_type,
25
27
  gateway: context["request.gateway"]
26
28
  })
27
29
 
@@ -40,28 +42,111 @@ module FlowChat
40
42
 
41
43
  def session_id(context)
42
44
  gateway = context["request.gateway"]
45
+ platform = context["request.platform"]
43
46
  flow_name = context["flow.name"]
44
47
 
45
- FlowChat.logger.debug { "Session::Middleware: Building session ID for gateway=#{gateway}, flow=#{flow_name}" }
48
+ # Check for explicit session ID first (for manual session management)
49
+ if context["session.id"].present?
50
+ session_id = context["session.id"]
51
+ FlowChat.logger.debug { "Session::Middleware: Using explicit session ID: #{session_id}" }
52
+ return session_id
53
+ end
54
+
55
+ FlowChat.logger.debug { "Session::Middleware: Building session ID for platform=#{platform}, gateway=#{gateway}, flow=#{flow_name}" }
46
56
 
47
- case gateway
48
- when :whatsapp_cloud_api
49
- # For WhatsApp, use phone number + flow name for consistent sessions
57
+ # Get identifier based on configuration
58
+ identifier = get_session_identifier(context)
59
+
60
+ # Build session ID based on configuration
61
+ session_id = build_session_id(flow_name, platform, gateway, identifier)
62
+ FlowChat.logger.debug { "Session::Middleware: Generated session ID: #{session_id}" }
63
+ session_id
64
+ end
65
+
66
+ def get_session_identifier(context)
67
+ identifier_type = @session_options.identifier
68
+
69
+ # If no identifier specified, use platform defaults
70
+ if identifier_type.nil?
71
+ platform = context["request.platform"]
72
+ identifier_type = case platform
73
+ when :ussd
74
+ :request_id # USSD defaults to ephemeral sessions
75
+ when :whatsapp
76
+ :msisdn # WhatsApp defaults to durable sessions
77
+ else
78
+ :msisdn # Default fallback to durable
79
+ end
80
+ end
81
+
82
+ case identifier_type
83
+ when :request_id
84
+ context["request.id"]
85
+ when :msisdn
50
86
  phone = context["request.msisdn"]
51
- session_id = "#{gateway}:#{flow_name}:#{phone}"
52
- FlowChat.logger.debug { "Session::Middleware: WhatsApp session ID created for phone #{phone}" }
53
- session_id
54
- # when :nalo, :nsano
55
- # # For USSD, use the request ID from the gateway
56
- # "#{gateway}:#{flow_name}:#{context["request.id"]}"
87
+ @session_options.hash_phone_numbers ? hash_phone_number(phone) : phone
57
88
  else
58
- # Fallback to request ID
59
- request_id = context["request.id"]
60
- session_id = "#{gateway}:#{flow_name}:#{request_id}"
61
- FlowChat.logger.debug { "Session::Middleware: Generic session ID created for request #{request_id}" }
62
- session_id
89
+ raise "Invalid session identifier type: #{identifier_type}"
63
90
  end
64
91
  end
92
+
93
+ def build_session_id(flow_name, platform, gateway, identifier)
94
+ parts = []
95
+
96
+ # Add flow name if flow isolation is enabled
97
+ parts << flow_name if @session_options.boundaries.include?(:flow)
98
+
99
+ # Add platform if platform isolation is enabled
100
+ parts << platform.to_s if @session_options.boundaries.include?(:platform)
101
+
102
+ # Add gateway if gateway isolation is enabled
103
+ parts << gateway.to_s if @session_options.boundaries.include?(:gateway)
104
+
105
+ # Add URL if URL isolation is enabled
106
+ if @session_options.boundaries.include?(:url)
107
+ url_identifier = get_url_identifier(context)
108
+ parts << url_identifier if url_identifier.present?
109
+ end
110
+
111
+ # Add the session identifier
112
+ parts << identifier if identifier.present?
113
+
114
+ # Join parts with colons
115
+ parts.join(":")
116
+ end
117
+
118
+ def get_url_identifier(context)
119
+ request = context.controller&.request
120
+ return nil unless request
121
+
122
+ # Extract host and path for URL boundary
123
+ host = request.host rescue nil
124
+ path = request.path rescue nil
125
+
126
+ # Create a normalized URL identifier: host + path
127
+ # e.g., "example.com/api/v1/ussd" or "tenant1.example.com/ussd"
128
+ url_parts = []
129
+ url_parts << host if host.present?
130
+ url_parts << path.sub(/^\//, '') if path.present? && path != '/'
131
+
132
+ # For long URLs, use first part + hash suffix instead of full hash
133
+ url_identifier = url_parts.join('/').gsub(/[^a-zA-Z0-9._-]/, '_')
134
+ if url_identifier.length > 50
135
+ require 'digest'
136
+ # Take first 41 chars + hash suffix to keep it manageable but recognizable
137
+ first_part = url_identifier[0, 41]
138
+ hash_suffix = Digest::SHA256.hexdigest(url_identifier)[0, 8]
139
+ url_identifier = "#{first_part}_#{hash_suffix}"
140
+ end
141
+
142
+ url_identifier
143
+ end
144
+
145
+ def hash_phone_number(phone)
146
+ # Use SHA256 but only take first 8 characters for reasonable session IDs
147
+ require 'digest'
148
+ Digest::SHA256.hexdigest(phone.to_s)[0, 8]
149
+ end
65
150
  end
66
151
  end
67
152
  end
@@ -32,7 +32,7 @@ module FlowChat
32
32
  name: "USSD (Nalo)",
33
33
  description: "USSD integration using Nalo",
34
34
  processor_type: "ussd",
35
- provider: "nalo",
35
+ gateway: "nalo",
36
36
  endpoint: "/ussd",
37
37
  icon: "📱",
38
38
  color: "#28a745",
@@ -45,7 +45,7 @@ module FlowChat
45
45
  name: "WhatsApp (Cloud API)",
46
46
  description: "WhatsApp integration using Cloud API",
47
47
  processor_type: "whatsapp",
48
- provider: "cloud_api",
48
+ gateway: "cloud_api",
49
49
  endpoint: "/whatsapp/webhook",
50
50
  icon: "💬",
51
51
  color: "#25D366",
@@ -1057,8 +1057,8 @@
1057
1057
  <span class="config-detail-value">${config.endpoint}</span>
1058
1058
  </div>
1059
1059
  <div class="config-detail-item">
1060
- <span class="config-detail-label">Provider:</span>
1061
- <span class="config-detail-value">${config.provider}</span>
1060
+ <span class="config-detail-label">gateway:</span>
1061
+ <span class="config-detail-value">${config.gateway}</span>
1062
1062
  </div>
1063
1063
  <div class="config-detail-item">
1064
1064
  <span class="config-detail-label">Type:</span>
@@ -1304,7 +1304,7 @@
1304
1304
 
1305
1305
  let requestData = {}
1306
1306
 
1307
- switch (config.provider) {
1307
+ switch (config.gateway) {
1308
1308
  case 'nalo':
1309
1309
  requestData = {
1310
1310
  USERID: state.sessionId,
@@ -1322,7 +1322,7 @@
1322
1322
  }
1323
1323
  break
1324
1324
  default:
1325
- throw new Error(`Unsupported USSD provider: ${config.provider}`)
1325
+ throw new Error(`Unsupported USSD gateway: ${config.gateway}`)
1326
1326
  }
1327
1327
 
1328
1328
  try {
@@ -1342,7 +1342,7 @@
1342
1342
  throw new Error(`HTTP ${response.status}: ${response.statusText}`)
1343
1343
  }
1344
1344
 
1345
- switch (config.provider) {
1345
+ switch (config.gateway) {
1346
1346
  case 'nalo':
1347
1347
  displayUSSDResponse(data.MSG)
1348
1348
  state.isRunning = data.MSGTYPE
@@ -20,6 +20,7 @@ module FlowChat
20
20
  context["request.message_id"] = SecureRandom.uuid
21
21
  context["request.timestamp"] = Time.current.iso8601
22
22
  context["request.gateway"] = :nalo
23
+ context["request.platform"] = :ussd
23
24
  context["request.network"] = nil
24
25
  context["request.msisdn"] = Phonelib.parse(params["MSISDN"]).e164
25
26
  # context["request.type"] = params["MSGTYPE"] ? :initial : :response
@@ -22,6 +22,7 @@ module FlowChat
22
22
 
23
23
  # Set a basic message_id (can be enhanced based on actual Nsano implementation)
24
24
  context["request.message_id"] = SecureRandom.uuid
25
+ context["request.platform"] = :ussd
25
26
 
26
27
  # TODO: Implement Nsano-specific parameter parsing
27
28
  # For now, add basic instrumentation structure for when this is implemented
@@ -58,7 +59,7 @@ module FlowChat
58
59
  # if params["network"].present? && params["UserSessionID"].present?
59
60
  # request_id = "nsano::request_id::#{params["UserSessionID"]}"
60
61
  # context["ussd.request"] = {
61
- # provider: :nsano,
62
+ # gateway: :nsano,
62
63
  # network: params["network"].to_sym,
63
64
  # msisdn: Phonelib.parse(params["msisdn"]).e164,
64
65
  # type: Config.cache&.read(request_id).present? ? :response : :initial,
@@ -70,7 +71,7 @@ module FlowChat
70
71
 
71
72
  # status, headers, response = @app.call(context)
72
73
 
73
- # if context["ussd.response"].present? && context["ussd.request"][:provider] == :nsano
74
+ # if context["ussd.response"].present? && context["ussd.request"][:gateway] == :nsano
74
75
  # if context["ussd.response"][:type] == :terminal
75
76
  # Config.cache&.write(request_id, nil)
76
77
  # else
@@ -1,10 +1,11 @@
1
1
  module FlowChat
2
2
  module Ussd
3
3
  class Processor < FlowChat::BaseProcessor
4
- def use_resumable_sessions
5
- FlowChat.logger.debug { "Ussd::Processor: Enabling resumable sessions middleware" }
6
- middleware.insert_before 0, FlowChat::Ussd::Middleware::ResumableSession
7
- self
4
+ def use_durable_sessions(cross_gateway: false)
5
+ FlowChat.logger.debug { "Ussd::Processor: Enabling durable sessions via session configuration" }
6
+ use_session_config(
7
+ identifier: :msisdn # Use MSISDN for durable sessions
8
+ )
8
9
  end
9
10
 
10
11
  protected
@@ -21,9 +22,6 @@ module FlowChat
21
22
  def configure_middleware_stack(builder)
22
23
  FlowChat.logger.debug { "Ussd::Processor: Configuring USSD middleware stack" }
23
24
 
24
- builder.use FlowChat::Session::Middleware
25
- FlowChat.logger.debug { "Ussd::Processor: Added Session::Middleware" }
26
-
27
25
  builder.use FlowChat::Ussd::Middleware::Pagination
28
26
  FlowChat.logger.debug { "Ussd::Processor: Added Ussd::Middleware::Pagination" }
29
27
 
@@ -1,3 +1,3 @@
1
1
  module FlowChat
2
- VERSION = "0.7.0"
2
+ VERSION = "0.8.0"
3
3
  end
@@ -160,6 +160,7 @@ module FlowChat
160
160
 
161
161
  context["request.id"] = phone_number
162
162
  context["request.gateway"] = :whatsapp_cloud_api
163
+ context["request.platform"] = :whatsapp
163
164
  context["request.message_id"] = message_id
164
165
  context["request.msisdn"] = Phonelib.parse(phone_number).e164
165
166
  context["request.contact_name"] = contact_name
@@ -20,8 +20,6 @@ module FlowChat
20
20
 
21
21
  def configure_middleware_stack(builder)
22
22
  FlowChat.logger.debug { "Whatsapp::Processor: Configuring WhatsApp middleware stack" }
23
- builder.use FlowChat::Session::Middleware
24
- FlowChat.logger.debug { "Whatsapp::Processor: Added Session::Middleware" }
25
23
 
26
24
  builder.use middleware
27
25
  FlowChat.logger.debug { "Whatsapp::Processor: Added custom middleware" }
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: flow_chat
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stefan Froelich
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-06-07 00:00:00.000000000 Z
11
+ date: 2025-06-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: zeitwerk
@@ -107,6 +107,7 @@ files:
107
107
  - docs/images/simulator.png
108
108
  - docs/instrumentation.md
109
109
  - docs/media.md
110
+ - docs/sessions.md
110
111
  - docs/testing.md
111
112
  - docs/ussd-setup.md
112
113
  - docs/whatsapp-setup.md
@@ -139,7 +140,6 @@ files:
139
140
  - lib/flow_chat/ussd/middleware/choice_mapper.rb
140
141
  - lib/flow_chat/ussd/middleware/executor.rb
141
142
  - lib/flow_chat/ussd/middleware/pagination.rb
142
- - lib/flow_chat/ussd/middleware/resumable_session.rb
143
143
  - lib/flow_chat/ussd/processor.rb
144
144
  - lib/flow_chat/ussd/renderer.rb
145
145
  - lib/flow_chat/version.rb
@@ -1,39 +0,0 @@
1
- module FlowChat
2
- module Ussd
3
- module Middleware
4
- class ResumableSession
5
- def initialize(app)
6
- @app = app
7
- end
8
-
9
- def call(context)
10
- if FlowChat::Config.ussd.resumable_sessions_enabled && context["ussd.request"].present?
11
- # First, try to find any interruption session.
12
- # The session key can be:
13
- # - a global session (key: "global")
14
- # - a provider-specific session (key: <provider>)
15
- session_key = self.class.session_key(context)
16
- resumable_session = context["session.store"].get(session_key)
17
-
18
- if resumable_session.present? && valid?(resumable_session)
19
- context.merge! resumable_session
20
- end
21
- end
22
-
23
- @app.call(context)
24
- end
25
-
26
- private
27
-
28
- def valid?(session)
29
- return true unless FlowChat::Config.ussd.resumable_sessions_timeout_seconds
30
-
31
- last_active_at = Time.parse session.dig("context", "last_active_at")
32
- (Time.current - FlowChat::Config.ussd.resumable_sessions_timeout_seconds) < last_active_at
33
- rescue
34
- false
35
- end
36
- end
37
- end
38
- end
39
- end