flow_chat 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +1 -0
  3. data/README.md +642 -86
  4. data/examples/initializer.rb +31 -0
  5. data/examples/media_prompts_examples.rb +28 -0
  6. data/examples/multi_tenant_whatsapp_controller.rb +244 -0
  7. data/examples/ussd_controller.rb +264 -0
  8. data/examples/whatsapp_controller.rb +140 -0
  9. data/examples/whatsapp_media_examples.rb +406 -0
  10. data/examples/whatsapp_message_job.rb +111 -0
  11. data/lib/flow_chat/base_processor.rb +67 -0
  12. data/lib/flow_chat/config.rb +36 -0
  13. data/lib/flow_chat/session/cache_session_store.rb +84 -0
  14. data/lib/flow_chat/session/middleware.rb +14 -6
  15. data/lib/flow_chat/simulator/controller.rb +78 -0
  16. data/lib/flow_chat/simulator/views/simulator.html.erb +1707 -0
  17. data/lib/flow_chat/ussd/app.rb +25 -0
  18. data/lib/flow_chat/ussd/gateway/nalo.rb +2 -0
  19. data/lib/flow_chat/ussd/gateway/nsano.rb +6 -0
  20. data/lib/flow_chat/ussd/middleware/resumable_session.rb +1 -1
  21. data/lib/flow_chat/ussd/processor.rb +14 -42
  22. data/lib/flow_chat/ussd/prompt.rb +39 -5
  23. data/lib/flow_chat/version.rb +1 -1
  24. data/lib/flow_chat/whatsapp/app.rb +64 -0
  25. data/lib/flow_chat/whatsapp/client.rb +439 -0
  26. data/lib/flow_chat/whatsapp/configuration.rb +113 -0
  27. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +213 -0
  28. data/lib/flow_chat/whatsapp/middleware/executor.rb +30 -0
  29. data/lib/flow_chat/whatsapp/processor.rb +26 -0
  30. data/lib/flow_chat/whatsapp/prompt.rb +251 -0
  31. data/lib/flow_chat/whatsapp/send_job_support.rb +79 -0
  32. data/lib/flow_chat/whatsapp/template_manager.rb +162 -0
  33. data/lib/flow_chat.rb +1 -0
  34. metadata +21 -3
  35. data/lib/flow_chat/ussd/simulator/controller.rb +0 -51
  36. data/lib/flow_chat/ussd/simulator/views/simulator.html.erb +0 -239
@@ -0,0 +1,213 @@
1
+ require "net/http"
2
+ require "json"
3
+ require "phonelib"
4
+
5
+ module FlowChat
6
+ module Whatsapp
7
+ module Gateway
8
+ class CloudApi
9
+ WHATSAPP_API_URL = "https://graph.facebook.com/v18.0"
10
+
11
+ def initialize(app, config = nil)
12
+ @app = app
13
+ @config = config || FlowChat::Whatsapp::Configuration.from_credentials
14
+ @client = FlowChat::Whatsapp::Client.new(@config)
15
+ end
16
+
17
+ def call(context)
18
+ controller = context.controller
19
+ request = controller.request
20
+
21
+ # Handle webhook verification
22
+ if request.get? && request.params["hub.mode"] == "subscribe"
23
+ return handle_verification(context)
24
+ end
25
+
26
+ # Handle webhook messages
27
+ if request.post?
28
+ return handle_webhook(context)
29
+ end
30
+
31
+ controller.head :bad_request
32
+ end
33
+
34
+ # Expose client for out-of-band messaging
35
+ def client
36
+ @client
37
+ end
38
+
39
+ private
40
+
41
+ def determine_message_handler(context)
42
+ # Check for simulator parameter in request (highest priority)
43
+ if context["simulator_mode"] || context.controller.request.params["simulator_mode"]
44
+ return :simulator
45
+ end
46
+
47
+ # Use global WhatsApp configuration
48
+ FlowChat::Config.whatsapp.message_handling_mode
49
+ end
50
+
51
+ def handle_verification(context)
52
+ controller = context.controller
53
+ params = controller.request.params
54
+
55
+ verify_token = @config.verify_token
56
+
57
+ if params["hub.verify_token"] == verify_token
58
+ controller.render plain: params["hub.challenge"]
59
+ else
60
+ controller.head :forbidden
61
+ end
62
+ end
63
+
64
+ def handle_webhook(context)
65
+ controller = context.controller
66
+ body = JSON.parse(controller.request.body.read)
67
+
68
+ # Check for simulator mode parameter in request
69
+ if body.dig("simulator_mode") || controller.request.params["simulator_mode"]
70
+ context["simulator_mode"] = true
71
+ end
72
+
73
+ # Extract message data from WhatsApp webhook
74
+ entry = body.dig("entry", 0)
75
+ return controller.head :ok unless entry
76
+
77
+ changes = entry.dig("changes", 0)
78
+ return controller.head :ok unless changes
79
+
80
+ value = changes["value"]
81
+ return controller.head :ok unless value
82
+
83
+ # Handle incoming messages
84
+ if value["messages"]&.any?
85
+ message = value["messages"].first
86
+ contact = value["contacts"]&.first
87
+
88
+ context["request.id"] = message["from"]
89
+ context["request.gateway"] = :whatsapp_cloud_api
90
+ context["request.message_id"] = message["id"]
91
+ context["request.msisdn"] = Phonelib.parse(message["from"]).e164
92
+ context["request.contact_name"] = contact&.dig("profile", "name")
93
+ context["request.timestamp"] = message["timestamp"]
94
+
95
+ # Extract message content based on type
96
+ extract_message_content(message, context)
97
+
98
+ # Determine message handling mode
99
+ handler_mode = determine_message_handler(context)
100
+
101
+ # Process the message based on handling mode
102
+ case handler_mode
103
+ when :inline
104
+ handle_message_inline(context, controller)
105
+ when :background
106
+ handle_message_background(context, controller)
107
+ when :simulator
108
+ # Return early from simulator mode to preserve the JSON response
109
+ return handle_message_simulator(context, controller)
110
+ end
111
+ end
112
+
113
+ # Handle message status updates
114
+ if value["statuses"]&.any?
115
+ Rails.logger.info "WhatsApp status update: #{value["statuses"]}"
116
+ end
117
+
118
+ controller.head :ok
119
+ end
120
+
121
+ def extract_message_content(message, context)
122
+ case message["type"]
123
+ when "text"
124
+ context.input = message.dig("text", "body")
125
+ when "interactive"
126
+ # Handle button/list replies
127
+ if message.dig("interactive", "type") == "button_reply"
128
+ context.input = message.dig("interactive", "button_reply", "id")
129
+ elsif message.dig("interactive", "type") == "list_reply"
130
+ context.input = message.dig("interactive", "list_reply", "id")
131
+ end
132
+ when "location"
133
+ context["request.location"] = {
134
+ latitude: message.dig("location", "latitude"),
135
+ longitude: message.dig("location", "longitude"),
136
+ name: message.dig("location", "name"),
137
+ address: message.dig("location", "address")
138
+ }
139
+ context.input = "$location$"
140
+ when "image", "document", "audio", "video"
141
+ context["request.media"] = {
142
+ type: message["type"],
143
+ id: message.dig(message["type"], "id"),
144
+ mime_type: message.dig(message["type"], "mime_type"),
145
+ caption: message.dig(message["type"], "caption")
146
+ }
147
+ context.input = "$media$"
148
+ end
149
+ end
150
+
151
+ def handle_message_inline(context, controller)
152
+ response = @app.call(context)
153
+ if response
154
+ result = @client.send_message(context["request.msisdn"], response)
155
+ context["whatsapp.message_result"] = result
156
+ end
157
+ end
158
+
159
+ def handle_message_background(context, controller)
160
+ # Process the flow synchronously (maintaining controller context)
161
+ response = @app.call(context)
162
+
163
+ if response
164
+ # Queue only the response delivery asynchronously
165
+ send_data = {
166
+ msisdn: context["request.msisdn"],
167
+ response: response,
168
+ config_name: @config.name
169
+ }
170
+
171
+ # Get job class from configuration
172
+ job_class_name = FlowChat::Config.whatsapp.background_job_class
173
+
174
+ # Enqueue background job for sending only
175
+ begin
176
+ job_class = job_class_name.constantize
177
+ job_class.perform_later(send_data)
178
+ rescue NameError
179
+ # Fallback to inline sending if no job system
180
+ Rails.logger.warn "Background mode requested but no #{job_class_name} found. Falling back to inline sending."
181
+ result = @client.send_message(context["request.msisdn"], response)
182
+ context["whatsapp.message_result"] = result
183
+ end
184
+ end
185
+ end
186
+
187
+ def handle_message_simulator(context, controller)
188
+ response = @app.call(context)
189
+
190
+ if response
191
+ # For simulator mode, return the response data in the HTTP response
192
+ # instead of actually sending via WhatsApp API
193
+ message_payload = @client.build_message_payload(response, context["request.msisdn"])
194
+
195
+ simulator_response = {
196
+ mode: "simulator",
197
+ webhook_processed: true,
198
+ would_send: message_payload,
199
+ message_info: {
200
+ to: context["request.msisdn"],
201
+ contact_name: context["request.contact_name"],
202
+ timestamp: Time.now.iso8601
203
+ }
204
+ }
205
+
206
+ controller.render json: simulator_response
207
+ return
208
+ end
209
+ end
210
+ end
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,30 @@
1
+ module FlowChat
2
+ module Whatsapp
3
+ module Middleware
4
+ class Executor
5
+ def initialize(app)
6
+ @app = app
7
+ end
8
+
9
+ def call(context)
10
+ whatsapp_app = build_whatsapp_app context
11
+ flow = context.flow.new whatsapp_app
12
+ flow.send context["flow.action"]
13
+ rescue FlowChat::Interrupt::Prompt => e
14
+ # Return the interrupt data for WhatsApp message formatting
15
+ e.prompt
16
+ rescue FlowChat::Interrupt::Terminate => e
17
+ # Clean up session and return terminal message
18
+ context.session.destroy
19
+ e.prompt
20
+ end
21
+
22
+ private
23
+
24
+ def build_whatsapp_app(context)
25
+ FlowChat::Whatsapp::App.new(context)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,26 @@
1
+ module FlowChat
2
+ module Whatsapp
3
+ class Processor < FlowChat::BaseProcessor
4
+ def use_whatsapp_config(config)
5
+ @whatsapp_config = config
6
+ self
7
+ end
8
+
9
+ protected
10
+
11
+ def middleware_name
12
+ "whatsapp.middleware"
13
+ end
14
+
15
+ def build_middleware_stack
16
+ create_middleware_stack("whatsapp")
17
+ end
18
+
19
+ def configure_middleware_stack(builder)
20
+ builder.use FlowChat::Session::Middleware
21
+ builder.use middleware
22
+ builder.use FlowChat::Whatsapp::Middleware::Executor
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,251 @@
1
+ module FlowChat
2
+ module Whatsapp
3
+ class Prompt
4
+ attr_reader :input
5
+
6
+ def initialize(input)
7
+ @input = input
8
+ end
9
+
10
+ def ask(message, transform: nil, validate: nil, convert: nil, media: nil)
11
+ if input.present?
12
+ begin
13
+ processed_input = process_input(input, transform, validate, convert)
14
+ return processed_input unless processed_input.nil?
15
+ rescue FlowChat::Interrupt::Prompt => validation_error
16
+ # If validation failed, include the error message with the original prompt
17
+ error_message = validation_error.prompt[1]
18
+ combined_message = "#{message}\n\n#{error_message}"
19
+ raise FlowChat::Interrupt::Prompt.new([:text, combined_message, {}])
20
+ end
21
+ end
22
+
23
+ # Send message and wait for response, optionally with media
24
+ if media
25
+ raise FlowChat::Interrupt::Prompt.new(build_media_prompt(message, media))
26
+ else
27
+ raise FlowChat::Interrupt::Prompt.new([:text, message, {}])
28
+ end
29
+ end
30
+
31
+ def say(message, media: nil)
32
+ if media
33
+ raise FlowChat::Interrupt::Terminate.new(build_media_prompt(message, media))
34
+ else
35
+ raise FlowChat::Interrupt::Terminate.new([:text, message, {}])
36
+ end
37
+ end
38
+
39
+ def select(message, choices, transform: nil, validate: nil, convert: nil)
40
+ if input.present?
41
+ processed_input = process_selection(input, choices, transform, validate, convert)
42
+ return processed_input unless processed_input.nil?
43
+ end
44
+
45
+ # Validate choices
46
+ validate_choices(choices)
47
+
48
+ # Standard selection without media support
49
+ interactive_prompt = build_selection_prompt(message, choices)
50
+ raise FlowChat::Interrupt::Prompt.new(interactive_prompt)
51
+ end
52
+
53
+ def yes?(message, transform: nil, validate: nil, convert: nil)
54
+ if input.present?
55
+ processed_input = process_boolean(input, transform, validate, convert)
56
+ return processed_input unless processed_input.nil?
57
+ end
58
+
59
+ buttons = [
60
+ { id: "yes", title: "Yes" },
61
+ { id: "no", title: "No" }
62
+ ]
63
+ raise FlowChat::Interrupt::Prompt.new([:interactive_buttons, message, { buttons: buttons }])
64
+ end
65
+
66
+ private
67
+
68
+ def build_media_prompt(message, media)
69
+ media_type = media[:type] || :image
70
+ url = media[:url] || media[:path]
71
+ filename = media[:filename]
72
+
73
+ case media_type.to_sym
74
+ when :image
75
+ [:media_image, "", { url: url, caption: message }]
76
+ when :document
77
+ [:media_document, "", { url: url, caption: message, filename: filename }]
78
+ when :audio
79
+ [:media_audio, "", { url: url, caption: message }]
80
+ when :video
81
+ [:media_video, "", { url: url, caption: message }]
82
+ when :sticker
83
+ [:media_sticker, "", { url: url }] # Stickers don't support captions
84
+ else
85
+ raise ArgumentError, "Unsupported media type: #{media_type}"
86
+ end
87
+ end
88
+
89
+ def build_selection_prompt(message, choices)
90
+ # Determine the best way to present choices
91
+ if choices.is_a?(Array)
92
+ # Convert array to hash with index-based keys
93
+ choice_hash = choices.each_with_index.to_h { |choice, index| [index.to_s, choice] }
94
+ build_list_prompt(message, choice_hash)
95
+ elsif choices.is_a?(Hash)
96
+ if choices.length <= 3
97
+ # Use buttons for 3 or fewer choices
98
+ build_buttons_prompt(message, choices)
99
+ else
100
+ # Use list for more than 3 choices
101
+ build_list_prompt(message, choices)
102
+ end
103
+ else
104
+ raise ArgumentError, "choices must be an Array or Hash"
105
+ end
106
+ end
107
+
108
+ def build_buttons_prompt(message, choices)
109
+ buttons = choices.map do |key, value|
110
+ {
111
+ id: key.to_s,
112
+ title: truncate_text(value.to_s, 20) # WhatsApp button titles have a 20 character limit
113
+ }
114
+ end
115
+
116
+ [:interactive_buttons, message, { buttons: buttons }]
117
+ end
118
+
119
+ def build_list_prompt(message, choices)
120
+ items = choices.map do |key, value|
121
+ original_text = value.to_s
122
+ truncated_title = truncate_text(original_text, 24)
123
+
124
+ # If title was truncated, put full text in description (up to 72 chars)
125
+ description = if original_text.length > 24
126
+ truncate_text(original_text, 72)
127
+ else
128
+ nil
129
+ end
130
+
131
+ {
132
+ id: key.to_s,
133
+ title: truncated_title,
134
+ description: description
135
+ }.compact
136
+ end
137
+
138
+ # If 10 or fewer items, use single section
139
+ if items.length <= 10
140
+ sections = [
141
+ {
142
+ title: "Options",
143
+ rows: items
144
+ }
145
+ ]
146
+ else
147
+ # Paginate into multiple sections (max 10 items per section)
148
+ sections = items.each_slice(10).with_index.map do |section_items, index|
149
+ start_num = (index * 10) + 1
150
+ end_num = start_num + section_items.length - 1
151
+
152
+ {
153
+ title: "#{start_num}-#{end_num}",
154
+ rows: section_items
155
+ }
156
+ end
157
+ end
158
+
159
+ [:interactive_list, message, { sections: sections }]
160
+ end
161
+
162
+ def process_input(input, transform, validate, convert)
163
+ # Apply transformation
164
+ transformed_input = transform ? transform.call(input) : input
165
+
166
+ # Apply conversion first, then validation
167
+ converted_input = convert ? convert.call(transformed_input) : transformed_input
168
+
169
+ # Apply validation on converted value
170
+ if validate
171
+ error_message = validate.call(converted_input)
172
+ if error_message
173
+ raise FlowChat::Interrupt::Prompt.new([:text, error_message, {}])
174
+ end
175
+ end
176
+
177
+ converted_input
178
+ end
179
+
180
+ def process_selection(input, choices, transform, validate, convert)
181
+ choice_hash = choices.is_a?(Array) ?
182
+ choices.each_with_index.to_h { |choice, index| [index.to_s, choice] } :
183
+ choices
184
+
185
+ # Check if input matches a valid choice
186
+ if choice_hash.key?(input)
187
+ selected_value = choice_hash[input]
188
+ process_input(selected_value, transform, validate, convert)
189
+ elsif choice_hash.value?(input)
190
+ # Input matches a choice value directly
191
+ process_input(input, transform, validate, convert)
192
+ else
193
+ # Invalid choice
194
+ choice_list = choice_hash.map { |key, value| "#{key}: #{value}" }.join("\n")
195
+ error_message = "Invalid choice. Please select one of:\n#{choice_list}"
196
+ raise FlowChat::Interrupt::Prompt.new([:text, error_message, {}])
197
+ end
198
+ end
199
+
200
+ def process_boolean(input, transform, validate, convert)
201
+ boolean_value = case input.to_s.downcase
202
+ when "yes", "y", "1", "true"
203
+ true
204
+ when "no", "n", "0", "false"
205
+ false
206
+ else
207
+ nil
208
+ end
209
+
210
+ if boolean_value.nil?
211
+ raise FlowChat::Interrupt::Prompt.new([:text, "Please answer with Yes or No.", {}])
212
+ end
213
+
214
+ process_input(boolean_value, transform, validate, convert)
215
+ end
216
+
217
+ def validate_choices(choices)
218
+ # Check for empty choices
219
+ if choices.nil? || choices.empty?
220
+ raise ArgumentError, "choices cannot be empty"
221
+ end
222
+
223
+ choice_count = choices.is_a?(Array) ? choices.length : choices.length
224
+
225
+ # WhatsApp supports max 100 total items across all sections
226
+ if choice_count > 100
227
+ raise ArgumentError, "WhatsApp supports maximum 100 choice options, got #{choice_count}"
228
+ end
229
+
230
+ # Validate individual choice values
231
+ choices_to_validate = choices.is_a?(Array) ? choices : choices.values
232
+
233
+ choices_to_validate.each_with_index do |choice, index|
234
+ if choice.nil? || choice.to_s.strip.empty?
235
+ raise ArgumentError, "choice at index #{index} cannot be empty"
236
+ end
237
+
238
+ choice_text = choice.to_s
239
+ if choice_text.length > 100
240
+ raise ArgumentError, "choice '#{choice_text[0..20]}...' is too long (#{choice_text.length} chars). Maximum is 100 characters"
241
+ end
242
+ end
243
+ end
244
+
245
+ def truncate_text(text, length)
246
+ return text if text.length <= length
247
+ text[0, length - 3] + "..."
248
+ end
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,79 @@
1
+ module FlowChat
2
+ module Whatsapp
3
+ # Module to be included in background jobs for WhatsApp response delivery
4
+ # Only handles sending responses, not processing flows
5
+ module SendJobSupport
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ # Set up job configuration
10
+ queue_as :default
11
+ retry_on StandardError, wait: :exponentially_longer, attempts: 3
12
+ end
13
+
14
+ # Main job execution method for sending responses
15
+ def perform_whatsapp_send(send_data)
16
+ config = resolve_whatsapp_config(send_data)
17
+ client = FlowChat::Whatsapp::Client.new(config)
18
+
19
+ result = client.send_message(send_data[:msisdn], send_data[:response])
20
+
21
+ if result
22
+ Rails.logger.info "WhatsApp message sent successfully: #{result['messages']&.first&.dig('id')}"
23
+ on_whatsapp_send_success(send_data, result)
24
+ else
25
+ Rails.logger.error "Failed to send WhatsApp message to #{send_data[:msisdn]}"
26
+ raise "WhatsApp API call failed"
27
+ end
28
+ rescue => e
29
+ on_whatsapp_send_error(e, send_data)
30
+ handle_whatsapp_send_error(e, send_data, config)
31
+ end
32
+
33
+ private
34
+
35
+ # Resolve WhatsApp configuration by name or fallback
36
+ def resolve_whatsapp_config(send_data)
37
+ # Try to resolve by name first (preferred method)
38
+ if send_data[:config_name] && FlowChat::Whatsapp::Configuration.exists?(send_data[:config_name])
39
+ return FlowChat::Whatsapp::Configuration.get(send_data[:config_name])
40
+ end
41
+
42
+ # Final fallback to default configuration
43
+ FlowChat::Whatsapp::Configuration.from_credentials
44
+ end
45
+
46
+ # Handle errors with user notification
47
+ def handle_whatsapp_send_error(error, send_data, config = nil)
48
+ Rails.logger.error "WhatsApp send job error: #{error.message}"
49
+ Rails.logger.error error.backtrace&.join("\n") if error.backtrace
50
+
51
+ # Try to send error message to user if we have config
52
+ if config
53
+ begin
54
+ client = FlowChat::Whatsapp::Client.new(config)
55
+ client.send_text(
56
+ send_data[:msisdn],
57
+ "⚠️ We're experiencing technical difficulties. Please try again in a few minutes."
58
+ )
59
+ rescue => send_error
60
+ Rails.logger.error "Failed to send error message: #{send_error.message}"
61
+ end
62
+ end
63
+
64
+ # Re-raise for job retry logic
65
+ raise error
66
+ end
67
+
68
+ # Override this method in your job for custom behavior
69
+ def on_whatsapp_send_success(send_data, result)
70
+ # Optional callback for successful message sending
71
+ end
72
+
73
+ # Override this method in your job for custom error handling
74
+ def on_whatsapp_send_error(error, send_data)
75
+ # Optional callback for error handling
76
+ end
77
+ end
78
+ end
79
+ end