flow_chat 0.2.1 → 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,75 @@
1
+ module FlowChat
2
+ module Whatsapp
3
+ class Configuration
4
+ attr_accessor :access_token, :phone_number_id, :verify_token, :app_id, :app_secret,
5
+ :webhook_url, :webhook_verify_token, :business_account_id
6
+
7
+ def initialize
8
+ @access_token = nil
9
+ @phone_number_id = nil
10
+ @verify_token = nil
11
+ @app_id = nil
12
+ @app_secret = nil
13
+ @webhook_url = nil
14
+ @webhook_verify_token = nil
15
+ @business_account_id = nil
16
+ end
17
+
18
+ # Load configuration from Rails credentials or environment variables
19
+ def self.from_credentials
20
+ config = new
21
+
22
+ if defined?(Rails) && Rails.application.credentials.whatsapp
23
+ credentials = Rails.application.credentials.whatsapp
24
+ config.access_token = credentials[:access_token]
25
+ config.phone_number_id = credentials[:phone_number_id]
26
+ config.verify_token = credentials[:verify_token]
27
+ config.app_id = credentials[:app_id]
28
+ config.app_secret = credentials[:app_secret]
29
+ config.webhook_url = credentials[:webhook_url]
30
+ config.business_account_id = credentials[:business_account_id]
31
+ else
32
+ # Fallback to environment variables
33
+ config.access_token = ENV['WHATSAPP_ACCESS_TOKEN']
34
+ config.phone_number_id = ENV['WHATSAPP_PHONE_NUMBER_ID']
35
+ config.verify_token = ENV['WHATSAPP_VERIFY_TOKEN']
36
+ config.app_id = ENV['WHATSAPP_APP_ID']
37
+ config.app_secret = ENV['WHATSAPP_APP_SECRET']
38
+ config.webhook_url = ENV['WHATSAPP_WEBHOOK_URL']
39
+ config.business_account_id = ENV['WHATSAPP_BUSINESS_ACCOUNT_ID']
40
+ end
41
+
42
+ config
43
+ end
44
+
45
+ def valid?
46
+ access_token.present? && phone_number_id.present? && verify_token.present?
47
+ end
48
+
49
+ def webhook_configured?
50
+ webhook_url.present? && verify_token.present?
51
+ end
52
+
53
+ # API endpoints
54
+ def messages_url
55
+ "https://graph.facebook.com/v18.0/#{phone_number_id}/messages"
56
+ end
57
+
58
+ def media_url(media_id)
59
+ "https://graph.facebook.com/v18.0/#{media_id}"
60
+ end
61
+
62
+ def phone_numbers_url
63
+ "https://graph.facebook.com/v18.0/#{business_account_id}/phone_numbers"
64
+ end
65
+
66
+ # Headers for API requests
67
+ def api_headers
68
+ {
69
+ "Authorization" => "Bearer #{access_token}",
70
+ "Content-Type" => "application/json"
71
+ }
72
+ end
73
+ end
74
+ end
75
+ end
@@ -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
+ end
15
+
16
+ def call(context)
17
+ controller = context.controller
18
+ request = controller.request
19
+
20
+ # Handle webhook verification
21
+ if request.get? && request.params["hub.mode"] == "subscribe"
22
+ return handle_verification(context)
23
+ end
24
+
25
+ # Handle webhook messages
26
+ if request.post?
27
+ return handle_webhook(context)
28
+ end
29
+
30
+ controller.head :bad_request
31
+ end
32
+
33
+ private
34
+
35
+ def handle_verification(context)
36
+ controller = context.controller
37
+ params = controller.request.params
38
+
39
+ verify_token = @config.verify_token
40
+
41
+ if params["hub.verify_token"] == verify_token
42
+ controller.render plain: params["hub.challenge"]
43
+ else
44
+ controller.head :forbidden
45
+ end
46
+ end
47
+
48
+ def handle_webhook(context)
49
+ controller = context.controller
50
+ body = JSON.parse(controller.request.body.read)
51
+
52
+ # Extract message data from WhatsApp webhook
53
+ entry = body.dig("entry", 0)
54
+ return controller.head :ok unless entry
55
+
56
+ changes = entry.dig("changes", 0)
57
+ return controller.head :ok unless changes
58
+
59
+ value = changes["value"]
60
+ return controller.head :ok unless value
61
+
62
+ # Handle incoming messages
63
+ if value["messages"]&.any?
64
+ message = value["messages"].first
65
+ contact = value["contacts"]&.first
66
+
67
+ context["request.id"] = message["from"]
68
+ context["request.gateway"] = :whatsapp_cloud_api
69
+ context["request.message_id"] = message["id"]
70
+ context["request.msisdn"] = Phonelib.parse(message["from"]).e164
71
+ context["request.contact_name"] = contact&.dig("profile", "name")
72
+ context["request.timestamp"] = message["timestamp"]
73
+
74
+ # 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$"
103
+ end
104
+
105
+ response = @app.call(context)
106
+ send_whatsapp_message(context, response)
107
+ end
108
+
109
+ # Handle message status updates
110
+ if value["statuses"]&.any?
111
+ # Log status updates but don't process them
112
+ Rails.logger.info "WhatsApp status update: #{value["statuses"]}"
113
+ end
114
+
115
+ controller.head :ok
116
+ end
117
+
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
130
+
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
135
+
136
+ response = http.request(request)
137
+
138
+ unless response.is_a?(Net::HTTPSuccess)
139
+ Rails.logger.error "WhatsApp API error: #{response.body}"
140
+ end
141
+ end
142
+
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] || []
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
+ }
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,36 @@
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
+ 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
+ protected
19
+
20
+ def middleware_name
21
+ "whatsapp.middleware"
22
+ end
23
+
24
+ def build_middleware_stack
25
+ create_middleware_stack("whatsapp")
26
+ end
27
+
28
+ def configure_middleware_stack(builder)
29
+ builder.use gateway
30
+ builder.use FlowChat::Session::Middleware
31
+ builder.use middleware
32
+ builder.use FlowChat::Whatsapp::Middleware::Executor
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,206 @@
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)
11
+ if input.present?
12
+ processed_input = process_input(input, transform, validate, convert)
13
+ return processed_input unless processed_input.nil?
14
+ end
15
+
16
+ # Send message and wait for response
17
+ raise FlowChat::Interrupt::Prompt.new([:text, message, {}])
18
+ end
19
+
20
+ def select(message, choices, transform: nil, validate: nil, convert: nil)
21
+ if input.present?
22
+ processed_input = process_selection(input, choices, transform, validate, convert)
23
+ return processed_input unless processed_input.nil?
24
+ end
25
+
26
+ # Validate choices
27
+ validate_choices(choices)
28
+
29
+ # Determine the best way to present choices
30
+ if choices.is_a?(Array)
31
+ # Convert array to hash with index-based keys
32
+ choice_hash = choices.each_with_index.to_h { |choice, index| [index.to_s, choice] }
33
+ present_choices_as_list(message, choice_hash)
34
+ elsif choices.is_a?(Hash)
35
+ if choices.length <= 3
36
+ # Use buttons for 3 or fewer choices
37
+ present_choices_as_buttons(message, choices)
38
+ else
39
+ # Use list for more than 3 choices
40
+ present_choices_as_list(message, choices)
41
+ end
42
+ else
43
+ raise ArgumentError, "choices must be an Array or Hash"
44
+ end
45
+ end
46
+
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?
51
+ end
52
+
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 }])
59
+ end
60
+
61
+ private
62
+
63
+ def process_input(input, transform, validate, convert)
64
+ # Apply transformation
65
+ transformed_input = transform ? transform.call(input) : input
66
+
67
+ # Apply conversion first, then validation
68
+ converted_input = convert ? convert.call(transformed_input) : transformed_input
69
+
70
+ # Apply validation on converted value
71
+ if validate
72
+ error_message = validate.call(converted_input)
73
+ if error_message
74
+ raise FlowChat::Interrupt::Prompt.new([:text, error_message, {}])
75
+ end
76
+ end
77
+
78
+ converted_input
79
+ end
80
+
81
+ 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] } :
84
+ choices
85
+
86
+ # Check if input matches a valid choice
87
+ if choice_hash.key?(input)
88
+ selected_value = choice_hash[input]
89
+ process_input(selected_value, transform, validate, convert)
90
+ elsif choice_hash.value?(input)
91
+ # Input matches a choice value directly
92
+ process_input(input, transform, validate, convert)
93
+ else
94
+ # Invalid choice
95
+ choice_list = choice_hash.map { |key, value| "#{key}: #{value}" }.join("\n")
96
+ error_message = "Invalid choice. Please select one of:\n#{choice_list}"
97
+ raise FlowChat::Interrupt::Prompt.new([:text, error_message, {}])
98
+ end
99
+ end
100
+
101
+ def process_boolean(input, transform, validate, convert)
102
+ 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
110
+
111
+ if boolean_value.nil?
112
+ raise FlowChat::Interrupt::Prompt.new([:text, "Please answer with Yes or No.", {}])
113
+ end
114
+
115
+ process_input(boolean_value, transform, validate, convert)
116
+ end
117
+
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
+ def validate_choices(choices)
173
+ # Check for empty choices
174
+ if choices.nil? || choices.empty?
175
+ raise ArgumentError, "choices cannot be empty"
176
+ end
177
+
178
+ choice_count = choices.is_a?(Array) ? choices.length : choices.length
179
+
180
+ # WhatsApp supports max 100 total items across all sections
181
+ if choice_count > 100
182
+ raise ArgumentError, "WhatsApp supports maximum 100 choice options, got #{choice_count}"
183
+ end
184
+
185
+ # Validate individual choice values
186
+ choices_to_validate = choices.is_a?(Array) ? choices : choices.values
187
+
188
+ choices_to_validate.each_with_index do |choice, index|
189
+ if choice.nil? || choice.to_s.strip.empty?
190
+ raise ArgumentError, "choice at index #{index} cannot be empty"
191
+ end
192
+
193
+ choice_text = choice.to_s
194
+ if choice_text.length > 100
195
+ raise ArgumentError, "choice '#{choice_text[0..20]}...' is too long (#{choice_text.length} chars). Maximum is 100 characters"
196
+ end
197
+ end
198
+ end
199
+
200
+ def truncate_text(text, length)
201
+ return text if text.length <= length
202
+ text[0, length - 3] + "..."
203
+ end
204
+ end
205
+ end
206
+ end