flow_chat 0.4.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.
@@ -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,8 +31,23 @@ module FlowChat
30
31
  controller.head :bad_request
31
32
  end
32
33
 
34
+ # Expose client for out-of-band messaging
35
+ def client
36
+ @client
37
+ end
38
+
33
39
  private
34
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
+
35
51
  def handle_verification(context)
36
52
  controller = context.controller
37
53
  params = controller.request.params
@@ -49,6 +65,11 @@ module FlowChat
49
65
  controller = context.controller
50
66
  body = JSON.parse(controller.request.body.read)
51
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
+
52
73
  # Extract message data from WhatsApp webhook
53
74
  entry = body.dig("entry", 0)
54
75
  return controller.head :ok unless entry
@@ -72,139 +93,118 @@ module FlowChat
72
93
  context["request.timestamp"] = message["timestamp"]
73
94
 
74
95
  # 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$"
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)
103
110
  end
104
-
105
- response = @app.call(context)
106
- send_whatsapp_message(context, response)
107
111
  end
108
112
 
109
113
  # Handle message status updates
110
114
  if value["statuses"]&.any?
111
- # Log status updates but don't process them
112
115
  Rails.logger.info "WhatsApp status update: #{value["statuses"]}"
113
116
  end
114
117
 
115
118
  controller.head :ok
116
119
  end
117
120
 
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"]
124
-
125
- message_data = build_message_payload(response, to)
126
-
127
- uri = URI("#{WHATSAPP_API_URL}/#{phone_number_id}/messages")
128
- http = Net::HTTP.new(uri.host, uri.port)
129
- http.use_ssl = true
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
130
150
 
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
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
135
158
 
136
- response = http.request(request)
159
+ def handle_message_background(context, controller)
160
+ # Process the flow synchronously (maintaining controller context)
161
+ response = @app.call(context)
137
162
 
138
- unless response.is_a?(Net::HTTPSuccess)
139
- Rails.logger.error "WhatsApp API error: #{response.body}"
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
140
184
  end
141
185
  end
142
186
 
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
- }
173
- }
174
- }
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] || []
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
198
203
  }
199
204
  }
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
- }
205
+
206
+ controller.render json: simulator_response
207
+ return
208
208
  end
209
209
  end
210
210
  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,7 +17,6 @@ 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
@@ -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
14
21
  end
15
22
 
16
- # Send message and wait for response
17
- raise FlowChat::Interrupt::Prompt.new([:text, message, {}])
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
18
37
  end
19
38
 
20
39
  def select(message, choices, transform: nil, validate: nil, convert: nil)
@@ -26,39 +45,119 @@ 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
+ 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
62
161
 
63
162
  def process_input(input, transform, validate, convert)
64
163
  # Apply transformation
@@ -115,60 +214,6 @@ module FlowChat
115
214
  process_input(boolean_value, transform, validate, convert)
116
215
  end
117
216
 
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
217
  def validate_choices(choices)
173
218
  # Check for empty choices
174
219
  if choices.nil? || choices.empty?
@@ -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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: flow_chat
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stefan Froelich
@@ -98,9 +98,12 @@ files:
98
98
  - bin/console
99
99
  - bin/setup
100
100
  - examples/initializer.rb
101
+ - examples/media_prompts_examples.rb
101
102
  - examples/multi_tenant_whatsapp_controller.rb
102
103
  - examples/ussd_controller.rb
103
104
  - examples/whatsapp_controller.rb
105
+ - examples/whatsapp_media_examples.rb
106
+ - examples/whatsapp_message_job.rb
104
107
  - flow_chat.gemspec
105
108
  - images/ussd_simulator.png
106
109
  - lib/flow_chat.rb
@@ -112,6 +115,8 @@ files:
112
115
  - lib/flow_chat/session/cache_session_store.rb
113
116
  - lib/flow_chat/session/middleware.rb
114
117
  - lib/flow_chat/session/rails_session_store.rb
118
+ - lib/flow_chat/simulator/controller.rb
119
+ - lib/flow_chat/simulator/views/simulator.html.erb
115
120
  - lib/flow_chat/ussd/app.rb
116
121
  - lib/flow_chat/ussd/gateway/nalo.rb
117
122
  - lib/flow_chat/ussd/gateway/nsano.rb
@@ -121,15 +126,15 @@ files:
121
126
  - lib/flow_chat/ussd/processor.rb
122
127
  - lib/flow_chat/ussd/prompt.rb
123
128
  - lib/flow_chat/ussd/renderer.rb
124
- - lib/flow_chat/ussd/simulator/controller.rb
125
- - lib/flow_chat/ussd/simulator/views/simulator.html.erb
126
129
  - lib/flow_chat/version.rb
127
130
  - lib/flow_chat/whatsapp/app.rb
131
+ - lib/flow_chat/whatsapp/client.rb
128
132
  - lib/flow_chat/whatsapp/configuration.rb
129
133
  - lib/flow_chat/whatsapp/gateway/cloud_api.rb
130
134
  - lib/flow_chat/whatsapp/middleware/executor.rb
131
135
  - lib/flow_chat/whatsapp/processor.rb
132
136
  - lib/flow_chat/whatsapp/prompt.rb
137
+ - lib/flow_chat/whatsapp/send_job_support.rb
133
138
  - lib/flow_chat/whatsapp/template_manager.rb
134
139
  homepage: https://github.com/radioactive-labs/flow_chat
135
140
  licenses: