flow_chat 0.4.0 → 0.4.2

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.
@@ -11,6 +11,7 @@ module FlowChat
11
11
  def initialize(app, config = nil)
12
12
  @app = app
13
13
  @config = config || FlowChat::Whatsapp::Configuration.from_credentials
14
+ @client = FlowChat::Whatsapp::Client.new(@config)
14
15
  end
15
16
 
16
17
  def call(context)
@@ -30,14 +31,27 @@ module FlowChat
30
31
  controller.head :bad_request
31
32
  end
32
33
 
34
+ # Expose client for out-of-band messaging
35
+ attr_reader :client
36
+
33
37
  private
34
38
 
39
+ def determine_message_handler(context)
40
+ # Check for simulator parameter in request (highest priority)
41
+ if context["simulator_mode"] || context.controller.request.params["simulator_mode"]
42
+ return :simulator
43
+ end
44
+
45
+ # Use global WhatsApp configuration
46
+ FlowChat::Config.whatsapp.message_handling_mode
47
+ end
48
+
35
49
  def handle_verification(context)
36
50
  controller = context.controller
37
51
  params = controller.request.params
38
52
 
39
53
  verify_token = @config.verify_token
40
-
54
+
41
55
  if params["hub.verify_token"] == verify_token
42
56
  controller.render plain: params["hub.challenge"]
43
57
  else
@@ -49,6 +63,11 @@ module FlowChat
49
63
  controller = context.controller
50
64
  body = JSON.parse(controller.request.body.read)
51
65
 
66
+ # Check for simulator mode parameter in request
67
+ if body.dig("simulator_mode") || controller.request.params["simulator_mode"]
68
+ context["simulator_mode"] = true
69
+ end
70
+
52
71
  # Extract message data from WhatsApp webhook
53
72
  entry = body.dig("entry", 0)
54
73
  return controller.head :ok unless entry
@@ -72,142 +91,121 @@ module FlowChat
72
91
  context["request.timestamp"] = message["timestamp"]
73
92
 
74
93
  # Extract message content based on type
75
- case message["type"]
76
- when "text"
77
- context.input = message.dig("text", "body")
78
- when "interactive"
79
- # Handle button/list replies
80
- if message.dig("interactive", "type") == "button_reply"
81
- context.input = message.dig("interactive", "button_reply", "id")
82
- elsif message.dig("interactive", "type") == "list_reply"
83
- context.input = message.dig("interactive", "list_reply", "id")
84
- end
85
- when "location"
86
- context["request.location"] = {
87
- latitude: message.dig("location", "latitude"),
88
- longitude: message.dig("location", "longitude"),
89
- name: message.dig("location", "name"),
90
- address: message.dig("location", "address")
91
- }
92
- # Set input so screen can proceed
93
- context.input = "$location$"
94
- when "image", "document", "audio", "video"
95
- context["request.media"] = {
96
- type: message["type"],
97
- id: message.dig(message["type"], "id"),
98
- mime_type: message.dig(message["type"], "mime_type"),
99
- caption: message.dig(message["type"], "caption")
100
- }
101
- # Set input so screen can proceed
102
- context.input = "$media$"
94
+ extract_message_content(message, context)
95
+
96
+ # Determine message handling mode
97
+ handler_mode = determine_message_handler(context)
98
+
99
+ # Process the message based on handling mode
100
+ case handler_mode
101
+ when :inline
102
+ handle_message_inline(context, controller)
103
+ when :background
104
+ handle_message_background(context, controller)
105
+ when :simulator
106
+ # Return early from simulator mode to preserve the JSON response
107
+ return handle_message_simulator(context, controller)
103
108
  end
104
-
105
- response = @app.call(context)
106
- send_whatsapp_message(context, response)
107
109
  end
108
110
 
109
111
  # Handle message status updates
110
112
  if value["statuses"]&.any?
111
- # Log status updates but don't process them
112
113
  Rails.logger.info "WhatsApp status update: #{value["statuses"]}"
113
114
  end
114
115
 
115
116
  controller.head :ok
116
117
  end
117
118
 
118
- def send_whatsapp_message(context, response)
119
- return unless response
120
-
121
- phone_number_id = @config.phone_number_id
122
- access_token = @config.access_token
123
- to = context["request.msisdn"]
119
+ def extract_message_content(message, context)
120
+ case message["type"]
121
+ when "text"
122
+ context.input = message.dig("text", "body")
123
+ when "interactive"
124
+ # Handle button/list replies
125
+ if message.dig("interactive", "type") == "button_reply"
126
+ context.input = message.dig("interactive", "button_reply", "id")
127
+ elsif message.dig("interactive", "type") == "list_reply"
128
+ context.input = message.dig("interactive", "list_reply", "id")
129
+ end
130
+ when "location"
131
+ context["request.location"] = {
132
+ latitude: message.dig("location", "latitude"),
133
+ longitude: message.dig("location", "longitude"),
134
+ name: message.dig("location", "name"),
135
+ address: message.dig("location", "address")
136
+ }
137
+ context.input = "$location$"
138
+ when "image", "document", "audio", "video"
139
+ context["request.media"] = {
140
+ type: message["type"],
141
+ id: message.dig(message["type"], "id"),
142
+ mime_type: message.dig(message["type"], "mime_type"),
143
+ caption: message.dig(message["type"], "caption")
144
+ }
145
+ context.input = "$media$"
146
+ end
147
+ end
124
148
 
125
- message_data = build_message_payload(response, to)
149
+ def handle_message_inline(context, controller)
150
+ response = @app.call(context)
151
+ if response
152
+ result = @client.send_message(context["request.msisdn"], response)
153
+ context["whatsapp.message_result"] = result
154
+ end
155
+ end
126
156
 
127
- uri = URI("#{WHATSAPP_API_URL}/#{phone_number_id}/messages")
128
- http = Net::HTTP.new(uri.host, uri.port)
129
- http.use_ssl = true
157
+ def handle_message_background(context, controller)
158
+ # Process the flow synchronously (maintaining controller context)
159
+ response = @app.call(context)
130
160
 
131
- request = Net::HTTP::Post.new(uri)
132
- request["Authorization"] = "Bearer #{access_token}"
133
- request["Content-Type"] = "application/json"
134
- request.body = message_data.to_json
161
+ if response
162
+ # Queue only the response delivery asynchronously
163
+ send_data = {
164
+ msisdn: context["request.msisdn"],
165
+ response: response,
166
+ config_name: @config.name
167
+ }
135
168
 
136
- response = http.request(request)
137
-
138
- unless response.is_a?(Net::HTTPSuccess)
139
- Rails.logger.error "WhatsApp API error: #{response.body}"
169
+ # Get job class from configuration
170
+ job_class_name = FlowChat::Config.whatsapp.background_job_class
171
+
172
+ # Enqueue background job for sending only
173
+ begin
174
+ job_class = job_class_name.constantize
175
+ job_class.perform_later(send_data)
176
+ rescue NameError
177
+ # Fallback to inline sending if no job system
178
+ Rails.logger.warn "Background mode requested but no #{job_class_name} found. Falling back to inline sending."
179
+ result = @client.send_message(context["request.msisdn"], response)
180
+ context["whatsapp.message_result"] = result
181
+ end
140
182
  end
141
183
  end
142
184
 
143
- def build_message_payload(response, to)
144
- type, content, options = response
145
-
146
- case type
147
- when :text
148
- {
149
- messaging_product: "whatsapp",
150
- to: to,
151
- type: "text",
152
- text: { body: content }
153
- }
154
- when :interactive_buttons
155
- {
156
- messaging_product: "whatsapp",
157
- to: to,
158
- type: "interactive",
159
- interactive: {
160
- type: "button",
161
- body: { text: content },
162
- action: {
163
- buttons: options[:buttons].map.with_index do |button, index|
164
- {
165
- type: "reply",
166
- reply: {
167
- id: button[:id] || index.to_s,
168
- title: button[:title]
169
- }
170
- }
171
- end
172
- }
185
+ def handle_message_simulator(context, controller)
186
+ response = @app.call(context)
187
+
188
+ if response
189
+ # For simulator mode, return the response data in the HTTP response
190
+ # instead of actually sending via WhatsApp API
191
+ message_payload = @client.build_message_payload(response, context["request.msisdn"])
192
+
193
+ simulator_response = {
194
+ mode: "simulator",
195
+ webhook_processed: true,
196
+ would_send: message_payload,
197
+ message_info: {
198
+ to: context["request.msisdn"],
199
+ contact_name: context["request.contact_name"],
200
+ timestamp: Time.now.iso8601
173
201
  }
174
202
  }
175
- when :interactive_list
176
- {
177
- messaging_product: "whatsapp",
178
- to: to,
179
- type: "interactive",
180
- interactive: {
181
- type: "list",
182
- body: { text: content },
183
- action: {
184
- button: options[:button_text] || "Choose",
185
- sections: options[:sections]
186
- }
187
- }
188
- }
189
- when :template
190
- {
191
- messaging_product: "whatsapp",
192
- to: to,
193
- type: "template",
194
- template: {
195
- name: options[:template_name],
196
- language: { code: options[:language] || "en_US" },
197
- components: options[:components] || []
198
- }
199
- }
200
- else
201
- # Default to text message
202
- {
203
- messaging_product: "whatsapp",
204
- to: to,
205
- type: "text",
206
- text: { body: content.to_s }
207
- }
203
+
204
+ controller.render json: simulator_response
205
+ nil
208
206
  end
209
207
  end
210
208
  end
211
209
  end
212
210
  end
213
- end
211
+ end
@@ -27,4 +27,4 @@ module FlowChat
27
27
  end
28
28
  end
29
29
  end
30
- end
30
+ end
@@ -6,15 +6,6 @@ module FlowChat
6
6
  self
7
7
  end
8
8
 
9
- def use_gateway(gateway_class)
10
- if gateway_class == FlowChat::Whatsapp::Gateway::CloudApi
11
- @gateway = @whatsapp_config ? gateway_class.new(nil, @whatsapp_config) : gateway_class.new(nil)
12
- else
13
- @gateway = gateway_class.new(nil)
14
- end
15
- self
16
- end
17
-
18
9
  protected
19
10
 
20
11
  def middleware_name
@@ -26,11 +17,10 @@ module FlowChat
26
17
  end
27
18
 
28
19
  def configure_middleware_stack(builder)
29
- builder.use gateway
30
20
  builder.use FlowChat::Session::Middleware
31
21
  builder.use middleware
32
22
  builder.use FlowChat::Whatsapp::Middleware::Executor
33
23
  end
34
24
  end
35
25
  end
36
- end
26
+ end
@@ -7,14 +7,33 @@ module FlowChat
7
7
  @input = input
8
8
  end
9
9
 
10
- def ask(message, transform: nil, validate: nil, convert: nil)
10
+ def ask(message, transform: nil, validate: nil, convert: nil, media: nil)
11
11
  if input.present?
12
- processed_input = process_input(input, transform, validate, convert)
13
- return processed_input unless processed_input.nil?
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, {}])
14
28
  end
29
+ end
15
30
 
16
- # Send message and wait for response
17
- raise FlowChat::Interrupt::Prompt.new([:text, message, {}])
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
18
37
  end
19
38
 
20
39
  def select(message, choices, transform: nil, validate: nil, convert: nil)
@@ -26,39 +45,117 @@ module FlowChat
26
45
  # Validate choices
27
46
  validate_choices(choices)
28
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)
29
90
  # Determine the best way to present choices
30
91
  if choices.is_a?(Array)
31
92
  # Convert array to hash with index-based keys
32
93
  choice_hash = choices.each_with_index.to_h { |choice, index| [index.to_s, choice] }
33
- present_choices_as_list(message, choice_hash)
94
+ build_list_prompt(message, choice_hash)
34
95
  elsif choices.is_a?(Hash)
35
96
  if choices.length <= 3
36
97
  # Use buttons for 3 or fewer choices
37
- present_choices_as_buttons(message, choices)
98
+ build_buttons_prompt(message, choices)
38
99
  else
39
100
  # Use list for more than 3 choices
40
- present_choices_as_list(message, choices)
101
+ build_list_prompt(message, choices)
41
102
  end
42
103
  else
43
104
  raise ArgumentError, "choices must be an Array or Hash"
44
105
  end
45
106
  end
46
107
 
47
- def yes?(message, transform: nil, validate: nil, convert: nil)
48
- if input.present?
49
- processed_input = process_boolean(input, transform, validate, convert)
50
- return processed_input unless processed_input.nil?
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
+ }
51
114
  end
52
115
 
53
- buttons = [
54
- { id: "yes", title: "Yes" },
55
- { id: "no", title: "No" }
56
- ]
57
-
58
- raise FlowChat::Interrupt::Prompt.new([:interactive_buttons, message, { buttons: buttons }])
116
+ [:interactive_buttons, message, {buttons: buttons}]
59
117
  end
60
118
 
61
- private
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
+ end
128
+
129
+ {
130
+ id: key.to_s,
131
+ title: truncated_title,
132
+ description: description
133
+ }.compact
134
+ end
135
+
136
+ # If 10 or fewer items, use single section
137
+ sections = if items.length <= 10
138
+ [
139
+ {
140
+ title: "Options",
141
+ rows: items
142
+ }
143
+ ]
144
+ else
145
+ # Paginate into multiple sections (max 10 items per section)
146
+ items.each_slice(10).with_index.map do |section_items, index|
147
+ start_num = (index * 10) + 1
148
+ end_num = start_num + section_items.length - 1
149
+
150
+ {
151
+ title: "#{start_num}-#{end_num}",
152
+ rows: section_items
153
+ }
154
+ end
155
+ end
156
+
157
+ [:interactive_list, message, {sections: sections}]
158
+ end
62
159
 
63
160
  def process_input(input, transform, validate, convert)
64
161
  # Apply transformation
@@ -79,8 +176,8 @@ module FlowChat
79
176
  end
80
177
 
81
178
  def process_selection(input, choices, transform, validate, convert)
82
- choice_hash = choices.is_a?(Array) ?
83
- choices.each_with_index.to_h { |choice, index| [index.to_s, choice] } :
179
+ choice_hash = choices.is_a?(Array) ?
180
+ choices.each_with_index.to_h { |choice, index| [index.to_s, choice] } :
84
181
  choices
85
182
 
86
183
  # Check if input matches a valid choice
@@ -100,13 +197,11 @@ module FlowChat
100
197
 
101
198
  def process_boolean(input, transform, validate, convert)
102
199
  boolean_value = case input.to_s.downcase
103
- when "yes", "y", "1", "true"
104
- true
105
- when "no", "n", "0", "false"
106
- false
107
- else
108
- nil
109
- end
200
+ when "yes", "y", "1", "true"
201
+ true
202
+ when "no", "n", "0", "false"
203
+ false
204
+ end
110
205
 
111
206
  if boolean_value.nil?
112
207
  raise FlowChat::Interrupt::Prompt.new([:text, "Please answer with Yes or No.", {}])
@@ -115,67 +210,13 @@ module FlowChat
115
210
  process_input(boolean_value, transform, validate, convert)
116
211
  end
117
212
 
118
- def present_choices_as_buttons(message, choices)
119
- buttons = choices.map do |key, value|
120
- {
121
- id: key.to_s,
122
- title: truncate_text(value.to_s, 20) # WhatsApp button titles have a 20 character limit
123
- }
124
- end
125
-
126
- raise FlowChat::Interrupt::Prompt.new([:interactive_buttons, message, { buttons: buttons }])
127
- end
128
-
129
- def present_choices_as_list(message, choices)
130
- items = choices.map do |key, value|
131
- original_text = value.to_s
132
- truncated_title = truncate_text(original_text, 24)
133
-
134
- # If title was truncated, put full text in description (up to 72 chars)
135
- description = if original_text.length > 24
136
- truncate_text(original_text, 72)
137
- else
138
- nil
139
- end
140
-
141
- {
142
- id: key.to_s,
143
- title: truncated_title,
144
- description: description
145
- }.compact
146
- end
147
-
148
- # If 10 or fewer items, use single section
149
- if items.length <= 10
150
- sections = [
151
- {
152
- title: "Options",
153
- rows: items
154
- }
155
- ]
156
- else
157
- # Paginate into multiple sections (max 10 items per section)
158
- sections = items.each_slice(10).with_index.map do |section_items, index|
159
- start_num = (index * 10) + 1
160
- end_num = start_num + section_items.length - 1
161
-
162
- {
163
- title: "#{start_num}-#{end_num}",
164
- rows: section_items
165
- }
166
- end
167
- end
168
-
169
- raise FlowChat::Interrupt::Prompt.new([:interactive_list, message, { sections: sections }])
170
- end
171
-
172
213
  def validate_choices(choices)
173
214
  # Check for empty choices
174
215
  if choices.nil? || choices.empty?
175
216
  raise ArgumentError, "choices cannot be empty"
176
217
  end
177
218
 
178
- choice_count = choices.is_a?(Array) ? choices.length : choices.length
219
+ choice_count = choices.length
179
220
 
180
221
  # WhatsApp supports max 100 total items across all sections
181
222
  if choice_count > 100
@@ -203,4 +244,4 @@ module FlowChat
203
244
  end
204
245
  end
205
246
  end
206
- end
247
+ 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