flow_chat 0.3.0 → 0.4.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.
@@ -0,0 +1,264 @@
1
+ # Example USSD Controller
2
+ # Add this to your Rails application as app/controllers/ussd_controller.rb
3
+
4
+ class UssdController < ApplicationController
5
+ skip_forgery_protection
6
+
7
+ def process_request
8
+ processor = FlowChat::Ussd::Processor.new(self) do |config|
9
+ config.use_gateway FlowChat::Ussd::Gateway::Nalo
10
+ # Use Rails session for USSD (shorter sessions)
11
+ config.use_session_store FlowChat::Session::RailsSessionStore
12
+
13
+ # Enable resumable sessions (optional)
14
+ config.use_resumable_sessions
15
+ end
16
+
17
+ processor.run WelcomeFlow, :main_page
18
+ end
19
+ end
20
+
21
+ # Example Flow for USSD
22
+ # Add this to your Rails application as app/flow_chat/welcome_flow.rb
23
+
24
+ class WelcomeFlow < FlowChat::Flow
25
+ def main_page
26
+ # Welcome the user
27
+ name = app.screen(:name) do |prompt|
28
+ prompt.ask "Welcome to our service! What's your name?",
29
+ transform: ->(input) { input.strip.titleize }
30
+ end
31
+
32
+ # Show main menu with numbered options (USSD style)
33
+ choice = app.screen(:main_menu) do |prompt|
34
+ prompt.select "Hi #{name}! Choose an option:", {
35
+ "1" => "Account Info",
36
+ "2" => "Make Payment",
37
+ "3" => "Get Balance",
38
+ "4" => "Customer Support"
39
+ }
40
+ end
41
+
42
+ case choice
43
+ when "1"
44
+ show_account_info
45
+ when "2"
46
+ make_payment
47
+ when "3"
48
+ get_balance
49
+ when "4"
50
+ customer_support
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def show_account_info
57
+ info_choice = app.screen(:account_info) do |prompt|
58
+ prompt.select "Account Information:", {
59
+ "1" => "Personal Details",
60
+ "2" => "Account Balance",
61
+ "3" => "Transaction History",
62
+ "0" => "Back to Main Menu"
63
+ }
64
+ end
65
+
66
+ case info_choice
67
+ when "1"
68
+ app.say "Name: John Doe\\nPhone: #{app.phone_number}\\nAccount: Active"
69
+ when "2"
70
+ app.say "Current Balance: $150.75\\nAvailable Credit: $1,000.00"
71
+ when "3"
72
+ app.say "Last 3 Transactions:\\n1. +$50.00 - Deposit\\n2. -$25.50 - Purchase\\n3. -$15.00 - Transfer"
73
+ when "0"
74
+ main_page # Go back to main menu
75
+ end
76
+ end
77
+
78
+ def make_payment
79
+ amount = app.screen(:payment_amount) do |prompt|
80
+ prompt.ask "Enter amount to pay:",
81
+ convert: ->(input) { input.to_f },
82
+ validate: ->(amount) {
83
+ return "Amount must be greater than 0" unless amount > 0
84
+ return "Maximum payment is $500" unless amount <= 500
85
+ nil
86
+ }
87
+ end
88
+
89
+ recipient = app.screen(:payment_recipient) do |prompt|
90
+ prompt.ask "Enter recipient phone number:",
91
+ validate: ->(input) {
92
+ return "Phone number must be 10 digits" unless input.match?(/\\A\\d{10}\\z/)
93
+ nil
94
+ }
95
+ end
96
+
97
+ # Confirmation screen
98
+ confirmed = app.screen(:payment_confirmation) do |prompt|
99
+ prompt.yes? "Pay $#{amount} to #{recipient}?\\nConfirm payment?"
100
+ end
101
+
102
+ if confirmed
103
+ # Process payment (your business logic here)
104
+ transaction_id = process_payment(amount, recipient)
105
+ app.say "Payment successful!\\nTransaction ID: #{transaction_id}\\nAmount: $#{amount}\\nTo: #{recipient}"
106
+ else
107
+ app.say "Payment cancelled"
108
+ end
109
+ end
110
+
111
+ def get_balance
112
+ # Simulate balance check
113
+ balance = check_account_balance(app.phone_number)
114
+ app.say "Account Balance\\n\\nAvailable: $#{balance[:available]}\\nPending: $#{balance[:pending]}\\nTotal: $#{balance[:total]}"
115
+ end
116
+
117
+ def customer_support
118
+ support_choice = app.screen(:support_menu) do |prompt|
119
+ prompt.select "Customer Support:", {
120
+ "1" => "Report an Issue",
121
+ "2" => "Account Questions",
122
+ "3" => "Technical Support",
123
+ "4" => "Speak to Agent",
124
+ "0" => "Main Menu"
125
+ }
126
+ end
127
+
128
+ case support_choice
129
+ when "1"
130
+ report_issue
131
+ when "2"
132
+ app.say "For account questions:\\nCall: 123-456-7890\\nEmail: support@company.com\\nHours: 9AM-5PM Mon-Fri"
133
+ when "3"
134
+ app.say "Technical Support:\\nCall: 123-456-7891\\nEmail: tech@company.com\\n24/7 Support Available"
135
+ when "4"
136
+ app.say "Connecting you to an agent...\\nPlease call 123-456-7890\\nOr visit our nearest branch"
137
+ when "0"
138
+ main_page
139
+ end
140
+ end
141
+
142
+ def report_issue
143
+ issue_type = app.screen(:issue_type) do |prompt|
144
+ prompt.select "Select issue type:", {
145
+ "1" => "Payment Problem",
146
+ "2" => "Account Access",
147
+ "3" => "Service Error",
148
+ "4" => "Other"
149
+ }
150
+ end
151
+
152
+ description = app.screen(:issue_description) do |prompt|
153
+ prompt.ask "Briefly describe the issue:",
154
+ validate: ->(input) {
155
+ return "Description must be at least 10 characters" unless input.length >= 10
156
+ nil
157
+ }
158
+ end
159
+
160
+ # Save the issue (your business logic here)
161
+ ticket_id = create_support_ticket(issue_type, description, app.phone_number)
162
+
163
+ app.say "Issue reported successfully!\\n\\nTicket ID: #{ticket_id}\\nWe'll contact you within 24 hours.\\n\\nThank you!"
164
+ end
165
+
166
+ # Helper methods (implement your business logic)
167
+
168
+ def process_payment(amount, recipient)
169
+ # Your payment processing logic here
170
+ # Return transaction ID
171
+ "TXN#{rand(100000..999999)}"
172
+ end
173
+
174
+ def check_account_balance(phone_number)
175
+ # Your balance checking logic here
176
+ {
177
+ available: "150.75",
178
+ pending: "25.00",
179
+ total: "175.75"
180
+ }
181
+ end
182
+
183
+ def create_support_ticket(issue_type, description, phone_number)
184
+ # Your ticket creation logic here
185
+ Rails.logger.info "Support ticket created: #{issue_type} - #{description} from #{phone_number}"
186
+ "TICKET#{rand(10000..99999)}"
187
+ end
188
+ end
189
+
190
+ # Configuration Examples:
191
+
192
+ # 1. Basic configuration with custom pagination
193
+ class UssdController < ApplicationController
194
+ skip_forgery_protection
195
+
196
+ def process_request
197
+ # Configure pagination for shorter messages
198
+ FlowChat::Config.ussd.pagination_page_size = 120
199
+ FlowChat::Config.ussd.pagination_next_option = "#"
200
+ FlowChat::Config.ussd.pagination_back_option = "*"
201
+
202
+ processor = FlowChat::Ussd::Processor.new(self) do |config|
203
+ config.use_gateway FlowChat::Ussd::Gateway::Nalo
204
+ config.use_session_store FlowChat::Session::RailsSessionStore
205
+ end
206
+
207
+ processor.run WelcomeFlow, :main_page
208
+ end
209
+ end
210
+
211
+ # 2. Configuration with custom middleware
212
+ class LoggingMiddleware
213
+ def initialize(app)
214
+ @app = app
215
+ end
216
+
217
+ def call(context)
218
+ Rails.logger.info "USSD Request from #{context['request.msisdn']}: #{context.input}"
219
+ start_time = Time.current
220
+
221
+ result = @app.call(context)
222
+
223
+ duration = Time.current - start_time
224
+ Rails.logger.info "USSD Response (#{duration.round(3)}s): #{result[1]}"
225
+
226
+ result
227
+ end
228
+ end
229
+
230
+ class UssdController < ApplicationController
231
+ skip_forgery_protection
232
+
233
+ def process_request
234
+ processor = FlowChat::Ussd::Processor.new(self) do |config|
235
+ config.use_gateway FlowChat::Ussd::Gateway::Nalo
236
+ config.use_session_store FlowChat::Session::RailsSessionStore
237
+ config.use_middleware LoggingMiddleware # Add custom logging
238
+ config.use_resumable_sessions # Enable resumable sessions
239
+ end
240
+
241
+ processor.run WelcomeFlow, :main_page
242
+ end
243
+ end
244
+
245
+ # 3. Configuration with cache-based sessions for longer persistence
246
+ class UssdController < ApplicationController
247
+ skip_forgery_protection
248
+
249
+ def process_request
250
+ processor = FlowChat::Ussd::Processor.new(self) do |config|
251
+ config.use_gateway FlowChat::Ussd::Gateway::Nalo
252
+ # Use cache store for longer session persistence
253
+ config.use_session_store FlowChat::Session::CacheSessionStore
254
+ end
255
+
256
+ processor.run WelcomeFlow, :main_page
257
+ end
258
+ end
259
+
260
+ # Add this route to your config/routes.rb:
261
+ # post '/ussd', to: 'ussd#process_request'
262
+
263
+ # For Nsano gateway, use:
264
+ # config.use_gateway FlowChat::Ussd::Gateway::Nsano
@@ -0,0 +1,141 @@
1
+ # Example WhatsApp Controller
2
+ # Add this to your Rails application as app/controllers/whatsapp_controller.rb
3
+
4
+ class WhatsappController < ApplicationController
5
+ skip_forgery_protection
6
+
7
+ def webhook
8
+ processor = FlowChat::Whatsapp::Processor.new(self) do |config|
9
+ config.use_gateway FlowChat::Whatsapp::Gateway::CloudApi
10
+ # Use cache-based session store for longer WhatsApp conversations
11
+ config.use_session_store FlowChat::Session::CacheSessionStore
12
+ end
13
+
14
+ processor.run WelcomeFlow, :main_page
15
+ end
16
+ end
17
+
18
+ # Example with Custom Configuration
19
+ class CustomWhatsappController < ApplicationController
20
+ skip_forgery_protection
21
+
22
+ def webhook
23
+ # Create custom WhatsApp configuration for this endpoint
24
+ custom_config = FlowChat::Whatsapp::Configuration.new
25
+ custom_config.access_token = ENV['MY_WHATSAPP_ACCESS_TOKEN']
26
+ custom_config.phone_number_id = ENV['MY_WHATSAPP_PHONE_NUMBER_ID']
27
+ custom_config.verify_token = ENV['MY_WHATSAPP_VERIFY_TOKEN']
28
+ custom_config.app_id = ENV['MY_WHATSAPP_APP_ID']
29
+ custom_config.app_secret = ENV['MY_WHATSAPP_APP_SECRET']
30
+ custom_config.business_account_id = ENV['MY_WHATSAPP_BUSINESS_ACCOUNT_ID']
31
+
32
+ processor = FlowChat::Whatsapp::Processor.new(self) do |config|
33
+ config.use_whatsapp_config(custom_config) # Use custom config
34
+ config.use_gateway FlowChat::Whatsapp::Gateway::CloudApi
35
+ config.use_session_store FlowChat::Session::CacheSessionStore
36
+ end
37
+
38
+ processor.run WelcomeFlow, :main_page
39
+ end
40
+ end
41
+
42
+ # Example Flow for WhatsApp
43
+ # Add this to your Rails application as app/flow_chat/welcome_flow.rb
44
+
45
+ class WelcomeFlow < FlowChat::Flow
46
+ def main_page
47
+ # Welcome the user
48
+ name = app.screen(:name) do |prompt|
49
+ prompt.ask "Hello! Welcome to our WhatsApp service. What's your name?",
50
+ transform: ->(input) { input.strip.titleize }
51
+ end
52
+
53
+ # Show main menu
54
+ choice = app.screen(:main_menu) do |prompt|
55
+ prompt.select "Hi #{name}! What can I help you with today?", {
56
+ "info" => "📋 Get Information",
57
+ "support" => "🆘 Contact Support",
58
+ "feedback" => "💬 Give Feedback"
59
+ }
60
+ end
61
+
62
+ case choice
63
+ when "info"
64
+ show_information_menu
65
+ when "support"
66
+ contact_support
67
+ when "feedback"
68
+ collect_feedback
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ def show_information_menu
75
+ info_choice = app.screen(:info_menu) do |prompt|
76
+ prompt.select "What information do you need?", {
77
+ "hours" => "🕒 Business Hours",
78
+ "location" => "📍 Our Location",
79
+ "services" => "🛠 Our Services"
80
+ }
81
+ end
82
+
83
+ case info_choice
84
+ when "hours"
85
+ app.say "We're open Monday-Friday 9AM-6PM, Saturday 9AM-2PM. Closed Sundays."
86
+ when "location"
87
+ app.say "📍 We're located at 123 Main Street, City, State 12345"
88
+ when "services"
89
+ app.say "Here are our main services:\\n\\n🌐 Web Development - Custom websites and applications\\n📱 Mobile Apps - iOS and Android development\\n🔧 Consulting - Technical consulting services"
90
+ end
91
+ end
92
+
93
+ def contact_support
94
+ # Use standard select menu instead of send_buttons
95
+ contact_method = app.screen(:contact_method) do |prompt|
96
+ prompt.select "How would you like to contact support?", {
97
+ "call" => "📞 Call Us",
98
+ "email" => "📧 Email Us",
99
+ "chat" => "💬 Live Chat"
100
+ }
101
+ end
102
+
103
+ case contact_method
104
+ when "call"
105
+ app.say "📞 You can call us at (555) 123-4567"
106
+ when "email"
107
+ app.say "📧 Send us an email at support@example.com"
108
+ when "chat"
109
+ app.say "💬 Our live chat is available on our website: www.example.com"
110
+ end
111
+ end
112
+
113
+ def collect_feedback
114
+ rating = app.screen(:rating) do |prompt|
115
+ prompt.select "How would you rate our service?", {
116
+ "5" => "⭐⭐⭐⭐⭐ Excellent",
117
+ "4" => "⭐⭐⭐⭐ Good",
118
+ "3" => "⭐⭐⭐ Average",
119
+ "2" => "⭐⭐ Poor",
120
+ "1" => "⭐ Very Poor"
121
+ }
122
+ end
123
+
124
+ feedback = app.screen(:feedback_text) do |prompt|
125
+ prompt.ask "Thank you for the #{rating}-star rating! Please share any additional feedback:"
126
+ end
127
+
128
+ # Save feedback (implement your logic here)
129
+ save_feedback(app.phone_number, rating, feedback)
130
+
131
+ app.say "Thank you for your feedback! We really appreciate it. 🙏"
132
+ end
133
+
134
+ def save_feedback(phone, rating, feedback)
135
+ # Implement your feedback saving logic here
136
+ Rails.logger.info "Feedback from #{phone}: #{rating} stars - #{feedback}"
137
+ end
138
+ end
139
+
140
+ # Add this route to your config/routes.rb:
141
+ # post '/whatsapp/webhook', to: 'whatsapp#webhook'
@@ -0,0 +1,63 @@
1
+ require "middleware"
2
+
3
+ module FlowChat
4
+ class BaseProcessor
5
+ attr_reader :middleware, :gateway
6
+
7
+ def initialize(controller)
8
+ @context = FlowChat::Context.new
9
+ @context["controller"] = controller
10
+ @middleware = ::Middleware::Builder.new(name: middleware_name)
11
+
12
+ yield self if block_given?
13
+ end
14
+
15
+ def use_gateway(gateway)
16
+ @gateway = gateway
17
+ self
18
+ end
19
+
20
+ def use_session_store(session_store)
21
+ @context["session.store"] = session_store
22
+ self
23
+ end
24
+
25
+ def use_middleware(middleware)
26
+ @middleware.use middleware
27
+ self
28
+ end
29
+
30
+ def run(flow_class, action)
31
+ @context["flow.name"] = flow_class.name.underscore
32
+ @context["flow.class"] = flow_class
33
+ @context["flow.action"] = action
34
+
35
+ stack = build_middleware_stack
36
+ yield stack if block_given?
37
+
38
+ stack.call(@context)
39
+ end
40
+
41
+ protected
42
+
43
+ # Subclasses should override these methods
44
+ def middleware_name
45
+ raise NotImplementedError, "Subclasses must implement middleware_name"
46
+ end
47
+
48
+ def build_middleware_stack
49
+ raise NotImplementedError, "Subclasses must implement build_middleware_stack"
50
+ end
51
+
52
+ # Helper method for building stacks
53
+ def create_middleware_stack(name)
54
+ ::Middleware::Builder.new(name: name) do |b|
55
+ configure_middleware_stack(b)
56
+ end.inject_logger(Rails.logger)
57
+ end
58
+
59
+ def configure_middleware_stack(builder)
60
+ raise NotImplementedError, "Subclasses must implement configure_middleware_stack"
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,84 @@
1
+ module FlowChat
2
+ module Session
3
+ class CacheSessionStore
4
+ def initialize(context, cache = nil)
5
+ @context = context
6
+ @cache = cache || FlowChat::Config.cache
7
+
8
+ raise ArgumentError, "Cache is required. Set FlowChat::Config.cache or pass a cache instance." unless @cache
9
+ end
10
+
11
+ def get(key)
12
+ return nil unless @context
13
+
14
+ data = @cache.read(session_key)
15
+ return nil unless data
16
+
17
+ data[key.to_s]
18
+ end
19
+
20
+ def set(key, value)
21
+ return unless @context
22
+
23
+ data = @cache.read(session_key) || {}
24
+ data[key.to_s] = value
25
+
26
+ @cache.write(session_key, data, expires_in: session_ttl)
27
+ value
28
+ end
29
+
30
+ def delete(key)
31
+ return unless @context
32
+
33
+ data = @cache.read(session_key)
34
+ return unless data
35
+
36
+ data.delete(key.to_s)
37
+ @cache.write(session_key, data, expires_in: session_ttl)
38
+ end
39
+
40
+ def clear
41
+ return unless @context
42
+
43
+ @cache.delete(session_key)
44
+ end
45
+
46
+ # Alias for compatibility
47
+ alias_method :destroy, :clear
48
+
49
+ def exists?
50
+ @cache.exist?(session_key)
51
+ end
52
+
53
+ private
54
+
55
+ def session_key
56
+ gateway = @context["request.gateway"]
57
+ msisdn = @context["request.msisdn"]
58
+
59
+ case gateway
60
+ when :whatsapp_cloud_api
61
+ "flow_chat:session:whatsapp:#{msisdn}"
62
+ when :nalo, :nsano
63
+ session_id = @context["request.id"]
64
+ "flow_chat:session:ussd:#{session_id}:#{msisdn}"
65
+ else
66
+ "flow_chat:session:unknown:#{msisdn}"
67
+ end
68
+ end
69
+
70
+ def session_ttl
71
+ gateway = @context["request.gateway"]
72
+
73
+ case gateway
74
+ when :whatsapp_cloud_api
75
+ 7.days # WhatsApp conversations can be long-lived
76
+ when :nalo, :nsano
77
+ 1.hour # USSD sessions are typically short
78
+ else
79
+ 1.day # Default
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -14,12 +14,20 @@ module FlowChat
14
14
  private
15
15
 
16
16
  def session_id(context)
17
- context["request.id"]
18
- # File.join(
19
- # context["PATH_INFO"],
20
- # (Config.resumable_sessions_enabled && Config.resumable_sessions_global) ? "global" : context["ussd.request"][:provider].to_s,
21
- # context["ussd.request"][:msisdn]
22
- # )
17
+ gateway = context["request.gateway"]
18
+ flow_name = context["flow.name"]
19
+ case gateway
20
+ when :whatsapp_cloud_api
21
+ # For WhatsApp, use phone number + flow name for consistent sessions
22
+ phone = context["request.msisdn"]
23
+ "#{gateway}:#{flow_name}:#{phone}"
24
+ # when :nalo, :nsano
25
+ # # For USSD, use the request ID from the gateway
26
+ # "#{gateway}:#{flow_name}:#{context["request.id"]}"
27
+ else
28
+ # Fallback to request ID
29
+ "#{gateway}:#{flow_name}:#{context["request.id"]}"
30
+ end
23
31
  end
24
32
  end
25
33
  end
@@ -28,6 +28,31 @@ module FlowChat
28
28
  def say(msg)
29
29
  raise FlowChat::Interrupt::Terminate.new(msg)
30
30
  end
31
+
32
+ # WhatsApp-specific data accessors (not supported in USSD)
33
+ def contact_name
34
+ nil
35
+ end
36
+
37
+ def message_id
38
+ context["request.message_id"]
39
+ end
40
+
41
+ def timestamp
42
+ context["request.timestamp"]
43
+ end
44
+
45
+ def location
46
+ nil
47
+ end
48
+
49
+ def media
50
+ nil
51
+ end
52
+
53
+ def phone_number
54
+ context["request.msisdn"]
55
+ end
31
56
  end
32
57
  end
33
58
  end
@@ -12,6 +12,8 @@ module FlowChat
12
12
  params = context.controller.request.params
13
13
 
14
14
  context["request.id"] = params["USERID"]
15
+ context["request.message_id"] = SecureRandom.uuid
16
+ context["request.timestamp"] = Time.current.iso8601
15
17
  context["request.gateway"] = :nalo
16
18
  context["request.network"] = nil
17
19
  context["request.msisdn"] = Phonelib.parse(params["MSISDN"]).e164
@@ -12,6 +12,12 @@ module FlowChat
12
12
  controller = context["controller"]
13
13
  controller.request
14
14
 
15
+ # Add timestamp for all requests
16
+ context["request.timestamp"] = Time.current.iso8601
17
+
18
+ # Set a basic message_id (can be enhanced based on actual Nsano implementation)
19
+ context["request.message_id"] = SecureRandom.uuid
20
+
15
21
  # input = context["rack.input"].read
16
22
  # context["rack.input"].rewind
17
23
  # if input.present?
@@ -29,7 +29,7 @@ module FlowChat
29
29
  return true unless FlowChat::Config.ussd.resumable_sessions_timeout_seconds
30
30
 
31
31
  last_active_at = Time.parse session.dig("context", "last_active_at")
32
- (Time.now - FlowChat::Config.ussd.resumable_sessions_timeout_seconds) < last_active_at
32
+ (Time.current - FlowChat::Config.ussd.resumable_sessions_timeout_seconds) < last_active_at
33
33
  rescue
34
34
  false
35
35
  end