flow_chat 0.4.1 → 0.5.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.
@@ -7,8 +7,6 @@ require "securerandom"
7
7
  module FlowChat
8
8
  module Whatsapp
9
9
  class Client
10
- WHATSAPP_API_URL = "https://graph.facebook.com/v18.0"
11
-
12
10
  def initialize(config)
13
11
  @config = config
14
12
  end
@@ -23,7 +21,7 @@ module FlowChat
23
21
  end
24
22
 
25
23
  # Send a text message
26
- # @param to [String] Phone number in E.164 format
24
+ # @param to [String] Phone number in E.164 format
27
25
  # @param text [String] Message text
28
26
  # @return [Hash] API response or nil on error
29
27
  def send_text(to, text)
@@ -36,7 +34,7 @@ module FlowChat
36
34
  # @param buttons [Array] Array of button hashes with :id and :title
37
35
  # @return [Hash] API response or nil on error
38
36
  def send_buttons(to, text, buttons)
39
- send_message(to, [:interactive_buttons, text, { buttons: buttons }])
37
+ send_message(to, [:interactive_buttons, text, {buttons: buttons}])
40
38
  end
41
39
 
42
40
  # Send interactive list
@@ -46,7 +44,7 @@ module FlowChat
46
44
  # @param button_text [String] Button text (default: "Choose")
47
45
  # @return [Hash] API response or nil on error
48
46
  def send_list(to, text, sections, button_text = "Choose")
49
- send_message(to, [:interactive_list, text, { sections: sections, button_text: button_text }])
47
+ send_message(to, [:interactive_list, text, {sections: sections, button_text: button_text}])
50
48
  end
51
49
 
52
50
  # Send a template message
@@ -56,10 +54,10 @@ module FlowChat
56
54
  # @param language [String] Language code (default: "en_US")
57
55
  # @return [Hash] API response or nil on error
58
56
  def send_template(to, template_name, components = [], language = "en_US")
59
- send_message(to, [:template, "", {
60
- template_name: template_name,
61
- components: components,
62
- language: language
57
+ send_message(to, [:template, "", {
58
+ template_name: template_name,
59
+ components: components,
60
+ language: language
63
61
  }])
64
62
  end
65
63
 
@@ -121,12 +119,12 @@ module FlowChat
121
119
  # @raise [StandardError] If upload fails
122
120
  def upload_media(file_path_or_io, mime_type, filename = nil)
123
121
  raise ArgumentError, "mime_type is required" if mime_type.nil? || mime_type.empty?
124
-
122
+
125
123
  if file_path_or_io.is_a?(String)
126
124
  # File path
127
125
  raise ArgumentError, "File not found: #{file_path_or_io}" unless File.exist?(file_path_or_io)
128
126
  filename ||= File.basename(file_path_or_io)
129
- file = File.open(file_path_or_io, 'rb')
127
+ file = File.open(file_path_or_io, "rb")
130
128
  else
131
129
  # IO object
132
130
  file = file_path_or_io
@@ -134,30 +132,30 @@ module FlowChat
134
132
  end
135
133
 
136
134
  # Upload directly via HTTP
137
- uri = URI("#{WHATSAPP_API_URL}/#{@config.phone_number_id}/media")
135
+ uri = URI("#{FlowChat::Config.whatsapp.api_base_url}/#{@config.phone_number_id}/media")
138
136
  http = Net::HTTP.new(uri.host, uri.port)
139
137
  http.use_ssl = true
140
138
 
141
139
  # Prepare multipart form data
142
140
  boundary = "----WebKitFormBoundary#{SecureRandom.hex(16)}"
143
-
141
+
144
142
  form_data = []
145
143
  form_data << "--#{boundary}"
146
144
  form_data << 'Content-Disposition: form-data; name="messaging_product"'
147
145
  form_data << ""
148
146
  form_data << "whatsapp"
149
-
147
+
150
148
  form_data << "--#{boundary}"
151
149
  form_data << "Content-Disposition: form-data; name=\"file\"; filename=\"#{filename}\""
152
150
  form_data << "Content-Type: #{mime_type}"
153
151
  form_data << ""
154
152
  form_data << file.read
155
-
153
+
156
154
  form_data << "--#{boundary}"
157
155
  form_data << 'Content-Disposition: form-data; name="type"'
158
156
  form_data << ""
159
157
  form_data << mime_type
160
-
158
+
161
159
  form_data << "--#{boundary}--"
162
160
 
163
161
  body = form_data.join("\r\n")
@@ -168,14 +166,10 @@ module FlowChat
168
166
  request.body = body
169
167
 
170
168
  response = http.request(request)
171
-
169
+
172
170
  if response.is_a?(Net::HTTPSuccess)
173
171
  data = JSON.parse(response.body)
174
- if data['id']
175
- data['id']
176
- else
177
- raise StandardError, "Failed to upload media: #{data}"
178
- end
172
+ data["id"] || raise(StandardError, "Failed to upload media: #{data}")
179
173
  else
180
174
  Rails.logger.error "WhatsApp Media Upload error: #{response.body}"
181
175
  raise StandardError, "Media upload failed: #{response.body}"
@@ -195,7 +189,7 @@ module FlowChat
195
189
  messaging_product: "whatsapp",
196
190
  to: to,
197
191
  type: "text",
198
- text: { body: content }
192
+ text: {body: content}
199
193
  }
200
194
  when :interactive_buttons
201
195
  {
@@ -204,7 +198,7 @@ module FlowChat
204
198
  type: "interactive",
205
199
  interactive: {
206
200
  type: "button",
207
- body: { text: content },
201
+ body: {text: content},
208
202
  action: {
209
203
  buttons: options[:buttons].map.with_index do |button, index|
210
204
  {
@@ -225,7 +219,7 @@ module FlowChat
225
219
  type: "interactive",
226
220
  interactive: {
227
221
  type: "list",
228
- body: { text: content },
222
+ body: {text: content},
229
223
  action: {
230
224
  button: options[:button_text] || "Choose",
231
225
  sections: options[:sections]
@@ -239,7 +233,7 @@ module FlowChat
239
233
  type: "template",
240
234
  template: {
241
235
  name: options[:template_name],
242
- language: { code: options[:language] || "en_US" },
236
+ language: {code: options[:language] || "en_US"},
243
237
  components: options[:components] || []
244
238
  }
245
239
  }
@@ -284,7 +278,7 @@ module FlowChat
284
278
  messaging_product: "whatsapp",
285
279
  to: to,
286
280
  type: "text",
287
- text: { body: content.to_s }
281
+ text: {body: content.to_s}
288
282
  }
289
283
  end
290
284
  end
@@ -293,7 +287,7 @@ module FlowChat
293
287
  # @param media_id [String] Media ID from WhatsApp
294
288
  # @return [String] Media URL or nil on error
295
289
  def get_media_url(media_id)
296
- uri = URI("#{WHATSAPP_API_URL}/#{media_id}")
290
+ uri = URI("#{FlowChat::Config.whatsapp.api_base_url}/#{media_id}")
297
291
  http = Net::HTTP.new(uri.host, uri.port)
298
292
  http.use_ssl = true
299
293
 
@@ -301,7 +295,7 @@ module FlowChat
301
295
  request["Authorization"] = "Bearer #{@config.access_token}"
302
296
 
303
297
  response = http.request(request)
304
-
298
+
305
299
  if response.is_a?(Net::HTTPSuccess)
306
300
  data = JSON.parse(response.body)
307
301
  data["url"]
@@ -326,7 +320,7 @@ module FlowChat
326
320
  request["Authorization"] = "Bearer #{@config.access_token}"
327
321
 
328
322
  response = http.request(request)
329
-
323
+
330
324
  if response.is_a?(Net::HTTPSuccess)
331
325
  response.body
332
326
  else
@@ -337,15 +331,15 @@ module FlowChat
337
331
 
338
332
  # Get MIME type from URL without downloading (HEAD request)
339
333
  def get_media_mime_type(url)
340
- require 'net/http'
341
-
334
+ require "net/http"
335
+
342
336
  uri = URI(url)
343
337
  http = Net::HTTP.new(uri.host, uri.port)
344
- http.use_ssl = (uri.scheme == 'https')
345
-
338
+ http.use_ssl = (uri.scheme == "https")
339
+
346
340
  # Use HEAD request to get headers without downloading content
347
341
  response = http.head(uri.path)
348
- response['content-type']
342
+ response["content-type"]
349
343
  rescue => e
350
344
  Rails.logger.warn "Could not detect MIME type for #{url}: #{e.message}"
351
345
  nil
@@ -358,7 +352,7 @@ module FlowChat
358
352
  # @return [Hash] Media object for WhatsApp API
359
353
  def build_media_object(options)
360
354
  media_obj = {}
361
-
355
+
362
356
  # Handle URL or ID
363
357
  if options[:url]
364
358
  # Use URL directly
@@ -367,17 +361,17 @@ module FlowChat
367
361
  # Use provided media ID directly
368
362
  media_obj[:id] = options[:id]
369
363
  end
370
-
364
+
371
365
  # Add optional fields
372
366
  media_obj[:caption] = options[:caption] if options[:caption]
373
367
  media_obj[:filename] = options[:filename] if options[:filename]
374
-
368
+
375
369
  media_obj
376
370
  end
377
371
 
378
372
  # Check if input is a URL or file path/media ID
379
373
  def url?(input)
380
- input.to_s.start_with?('http://', 'https://')
374
+ input.to_s.start_with?("http://", "https://")
381
375
  end
382
376
 
383
377
  # Extract filename from URL
@@ -393,7 +387,7 @@ module FlowChat
393
387
  # @param message_data [Hash] Message payload
394
388
  # @return [Hash] API response or nil on error
395
389
  def send_message_payload(message_data)
396
- uri = URI("#{WHATSAPP_API_URL}/#{@config.phone_number_id}/messages")
390
+ uri = URI("#{FlowChat::Config.whatsapp.api_base_url}/#{@config.phone_number_id}/messages")
397
391
  http = Net::HTTP.new(uri.host, uri.port)
398
392
  http.use_ssl = true
399
393
 
@@ -403,7 +397,7 @@ module FlowChat
403
397
  request.body = message_data.to_json
404
398
 
405
399
  response = http.request(request)
406
-
400
+
407
401
  if response.is_a?(Net::HTTPSuccess)
408
402
  JSON.parse(response.body)
409
403
  else
@@ -414,21 +408,21 @@ module FlowChat
414
408
 
415
409
  def send_media_message(to, media_type, url_or_id, caption: nil, filename: nil, mime_type: nil)
416
410
  media_object = if url?(url_or_id)
417
- { link: url_or_id }
418
- else
419
- { id: url_or_id }
420
- end
411
+ {link: url_or_id}
412
+ else
413
+ {id: url_or_id}
414
+ end
421
415
 
422
416
  # Add caption if provided (stickers don't support captions)
423
417
  media_object[:caption] = caption if caption && media_type != :sticker
424
-
418
+
425
419
  # Add filename for documents
426
420
  media_object[:filename] = filename if filename && media_type == :document
427
421
 
428
422
  message = {
429
- messaging_product: "whatsapp",
430
- to: to,
431
- type: media_type.to_s,
423
+ :messaging_product => "whatsapp",
424
+ :to => to,
425
+ :type => media_type.to_s,
432
426
  media_type.to_s => media_object
433
427
  }
434
428
 
@@ -436,4 +430,4 @@ module FlowChat
436
430
  end
437
431
  end
438
432
  end
439
- end
433
+ end
@@ -2,7 +2,7 @@ module FlowChat
2
2
  module Whatsapp
3
3
  class Configuration
4
4
  attr_accessor :access_token, :phone_number_id, :verify_token, :app_id, :app_secret,
5
- :webhook_url, :webhook_verify_token, :business_account_id, :name
5
+ :webhook_verify_token, :business_account_id, :name, :skip_signature_validation
6
6
 
7
7
  # Class-level storage for named configurations
8
8
  @@configurations = {}
@@ -14,9 +14,9 @@ module FlowChat
14
14
  @verify_token = nil
15
15
  @app_id = nil
16
16
  @app_secret = nil
17
- @webhook_url = nil
18
17
  @webhook_verify_token = nil
19
18
  @business_account_id = nil
19
+ @skip_signature_validation = false
20
20
 
21
21
  register_as(name) if name.present?
22
22
  end
@@ -24,7 +24,7 @@ module FlowChat
24
24
  # Load configuration from Rails credentials or environment variables
25
25
  def self.from_credentials
26
26
  config = new(nil)
27
-
27
+
28
28
  if defined?(Rails) && Rails.application.credentials.whatsapp
29
29
  credentials = Rails.application.credentials.whatsapp
30
30
  config.access_token = credentials[:access_token]
@@ -32,17 +32,17 @@ module FlowChat
32
32
  config.verify_token = credentials[:verify_token]
33
33
  config.app_id = credentials[:app_id]
34
34
  config.app_secret = credentials[:app_secret]
35
- config.webhook_url = credentials[:webhook_url]
36
35
  config.business_account_id = credentials[:business_account_id]
36
+ config.skip_signature_validation = credentials[:skip_signature_validation] || false
37
37
  else
38
38
  # Fallback to environment variables
39
- config.access_token = ENV['WHATSAPP_ACCESS_TOKEN']
40
- config.phone_number_id = ENV['WHATSAPP_PHONE_NUMBER_ID']
41
- config.verify_token = ENV['WHATSAPP_VERIFY_TOKEN']
42
- config.app_id = ENV['WHATSAPP_APP_ID']
43
- config.app_secret = ENV['WHATSAPP_APP_SECRET']
44
- config.webhook_url = ENV['WHATSAPP_WEBHOOK_URL']
45
- config.business_account_id = ENV['WHATSAPP_BUSINESS_ACCOUNT_ID']
39
+ config.access_token = ENV["WHATSAPP_ACCESS_TOKEN"]
40
+ config.phone_number_id = ENV["WHATSAPP_PHONE_NUMBER_ID"]
41
+ config.verify_token = ENV["WHATSAPP_VERIFY_TOKEN"]
42
+ config.app_id = ENV["WHATSAPP_APP_ID"]
43
+ config.app_secret = ENV["WHATSAPP_APP_SECRET"]
44
+ config.business_account_id = ENV["WHATSAPP_BUSINESS_ACCOUNT_ID"]
45
+ config.skip_signature_validation = ENV["WHATSAPP_SKIP_SIGNATURE_VALIDATION"] == "true"
46
46
  end
47
47
 
48
48
  config
@@ -81,24 +81,25 @@ module FlowChat
81
81
  end
82
82
 
83
83
  def valid?
84
- access_token.present? && phone_number_id.present? && verify_token.present?
85
- end
86
-
87
- def webhook_configured?
88
- webhook_url.present? && verify_token.present?
84
+ access_token && !access_token.to_s.empty? && phone_number_id && !phone_number_id.to_s.empty? && verify_token && !verify_token.to_s.empty?
89
85
  end
90
86
 
91
87
  # API endpoints
92
88
  def messages_url
93
- "https://graph.facebook.com/v18.0/#{phone_number_id}/messages"
89
+ "#{FlowChat::Config.whatsapp.api_base_url}/#{phone_number_id}/messages"
94
90
  end
95
91
 
96
92
  def media_url(media_id)
97
- "https://graph.facebook.com/v18.0/#{media_id}"
93
+ "#{FlowChat::Config.whatsapp.api_base_url}/#{media_id}"
98
94
  end
99
95
 
100
96
  def phone_numbers_url
101
- "https://graph.facebook.com/v18.0/#{business_account_id}/phone_numbers"
97
+ "#{FlowChat::Config.whatsapp.api_base_url}/#{business_account_id}/phone_numbers"
98
+ end
99
+
100
+ # Get API base URL from global config
101
+ def api_base_url
102
+ FlowChat::Config.whatsapp.api_base_url
102
103
  end
103
104
 
104
105
  # Headers for API requests
@@ -110,4 +111,4 @@ module FlowChat
110
111
  end
111
112
  end
112
113
  end
113
- end
114
+ end
@@ -1,13 +1,15 @@
1
1
  require "net/http"
2
2
  require "json"
3
3
  require "phonelib"
4
+ require "openssl"
4
5
 
5
6
  module FlowChat
6
7
  module Whatsapp
8
+ # Configuration-related errors
9
+ class ConfigurationError < StandardError; end
10
+
7
11
  module Gateway
8
12
  class CloudApi
9
- WHATSAPP_API_URL = "https://graph.facebook.com/v18.0"
10
-
11
13
  def initialize(app, config = nil)
12
14
  @app = app
13
15
  @config = config || FlowChat::Whatsapp::Configuration.from_credentials
@@ -32,15 +34,13 @@ module FlowChat
32
34
  end
33
35
 
34
36
  # Expose client for out-of-band messaging
35
- def client
36
- @client
37
- end
37
+ attr_reader :client
38
38
 
39
39
  private
40
40
 
41
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"]
42
+ # Check if simulator mode was already detected and set in context
43
+ if context["simulator_mode"]
44
44
  return :simulator
45
45
  end
46
46
 
@@ -53,7 +53,7 @@ module FlowChat
53
53
  params = controller.request.params
54
54
 
55
55
  verify_token = @config.verify_token
56
-
56
+
57
57
  if params["hub.verify_token"] == verify_token
58
58
  controller.render plain: params["hub.challenge"]
59
59
  else
@@ -63,15 +63,30 @@ module FlowChat
63
63
 
64
64
  def handle_webhook(context)
65
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"]
66
+
67
+ # Parse body
68
+ begin
69
+ parse_request_body(controller.request)
70
+ rescue JSON::ParserError => e
71
+ Rails.logger.warn "Failed to parse webhook body: #{e.message}"
72
+ return controller.head :bad_request
73
+ end
74
+
75
+ # Check for simulator mode parameter in request (before validation)
76
+ # But only enable if valid simulator token is provided
77
+ is_simulator_mode = simulate?(context)
78
+ if is_simulator_mode
70
79
  context["simulator_mode"] = true
71
80
  end
72
81
 
82
+ # Validate webhook signature for security (skip for simulator mode)
83
+ unless is_simulator_mode || valid_webhook_signature?(controller.request)
84
+ Rails.logger.warn "Invalid webhook signature received"
85
+ return controller.head :unauthorized
86
+ end
87
+
73
88
  # Extract message data from WhatsApp webhook
74
- entry = body.dig("entry", 0)
89
+ entry = @body.dig("entry", 0)
75
90
  return controller.head :ok unless entry
76
91
 
77
92
  changes = entry.dig("changes", 0)
@@ -118,6 +133,57 @@ module FlowChat
118
133
  controller.head :ok
119
134
  end
120
135
 
136
+ # Validate webhook signature to ensure request comes from WhatsApp
137
+ def valid_webhook_signature?(request)
138
+ # Check if signature validation is explicitly disabled
139
+ if @config.skip_signature_validation
140
+ return true
141
+ end
142
+
143
+ # Require app_secret for signature validation
144
+ unless @config.app_secret && !@config.app_secret.empty?
145
+ raise FlowChat::Whatsapp::ConfigurationError,
146
+ "WhatsApp app_secret is required for webhook signature validation. " \
147
+ "Either configure app_secret or set skip_signature_validation=true to explicitly disable validation."
148
+ end
149
+
150
+ signature_header = request.headers["X-Hub-Signature-256"]
151
+ return false unless signature_header
152
+
153
+ # Extract signature from header (format: "sha256=<signature>")
154
+ expected_signature = signature_header.sub("sha256=", "")
155
+
156
+ # Get raw request body
157
+ request.body.rewind
158
+ body = request.body.read
159
+ request.body.rewind
160
+
161
+ # Calculate HMAC signature
162
+ calculated_signature = OpenSSL::HMAC.hexdigest(
163
+ OpenSSL::Digest.new("sha256"),
164
+ @config.app_secret,
165
+ body
166
+ )
167
+
168
+ # Compare signatures using secure comparison to prevent timing attacks
169
+ secure_compare(expected_signature, calculated_signature)
170
+ rescue FlowChat::Whatsapp::ConfigurationError
171
+ raise
172
+ rescue => e
173
+ Rails.logger.error "Error validating webhook signature: #{e.message}"
174
+ false
175
+ end
176
+
177
+ # Secure string comparison to prevent timing attacks
178
+ def secure_compare(a, b)
179
+ return false unless a.bytesize == b.bytesize
180
+
181
+ l = a.unpack("C*")
182
+ res = 0
183
+ b.each_byte { |byte| res |= byte ^ l.shift }
184
+ res == 0
185
+ end
186
+
121
187
  def extract_message_content(message, context)
122
188
  case message["type"]
123
189
  when "text"
@@ -159,7 +225,7 @@ module FlowChat
159
225
  def handle_message_background(context, controller)
160
226
  # Process the flow synchronously (maintaining controller context)
161
227
  response = @app.call(context)
162
-
228
+
163
229
  if response
164
230
  # Queue only the response delivery asynchronously
165
231
  send_data = {
@@ -170,7 +236,7 @@ module FlowChat
170
236
 
171
237
  # Get job class from configuration
172
238
  job_class_name = FlowChat::Config.whatsapp.background_job_class
173
-
239
+
174
240
  # Enqueue background job for sending only
175
241
  begin
176
242
  job_class = job_class_name.constantize
@@ -186,12 +252,12 @@ module FlowChat
186
252
 
187
253
  def handle_message_simulator(context, controller)
188
254
  response = @app.call(context)
189
-
255
+
190
256
  if response
191
257
  # For simulator mode, return the response data in the HTTP response
192
258
  # instead of actually sending via WhatsApp API
193
259
  message_payload = @client.build_message_payload(response, context["request.msisdn"])
194
-
260
+
195
261
  simulator_response = {
196
262
  mode: "simulator",
197
263
  webhook_processed: true,
@@ -204,10 +270,54 @@ module FlowChat
204
270
  }
205
271
 
206
272
  controller.render json: simulator_response
207
- return
273
+ nil
274
+ end
275
+ end
276
+
277
+ def simulate?(context)
278
+ # Check if simulator mode is enabled for this processor
279
+ return false unless context["enable_simulator"]
280
+
281
+ # Then check if simulator mode is requested and valid
282
+ @body.dig("simulator_mode") && valid_simulator_cookie?(context)
283
+ end
284
+
285
+ def valid_simulator_cookie?(context)
286
+ simulator_secret = FlowChat::Config.simulator_secret
287
+ return false unless simulator_secret && !simulator_secret.empty?
288
+
289
+ # Check for simulator cookie
290
+ request = context.controller.request
291
+ simulator_cookie = request.cookies["flowchat_simulator"]
292
+ return false unless simulator_cookie
293
+
294
+ # Verify the cookie is a valid HMAC signature
295
+ # Cookie format: "timestamp:signature" where signature = HMAC(simulator_secret, "simulator:timestamp")
296
+ begin
297
+ timestamp_str, signature = simulator_cookie.split(":", 2)
298
+ return false unless timestamp_str && signature
299
+
300
+ # Check timestamp is recent (within 24 hours for reasonable session duration)
301
+ timestamp = timestamp_str.to_i
302
+ return false if timestamp <= 0
303
+ return false if (Time.now.to_i - timestamp).abs > 86400 # 24 hours
304
+
305
+ # Calculate expected signature
306
+ message = "simulator:#{timestamp_str}"
307
+ expected_signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), simulator_secret, message)
308
+
309
+ # Use secure comparison
310
+ secure_compare(signature, expected_signature)
311
+ rescue => e
312
+ Rails.logger.warn "Invalid simulator cookie format: #{e.message}"
313
+ false
208
314
  end
209
315
  end
316
+
317
+ def parse_request_body(request)
318
+ @body ||= JSON.parse(request.body.read)
319
+ end
210
320
  end
211
321
  end
212
322
  end
213
- end
323
+ end
@@ -27,4 +27,4 @@ module FlowChat
27
27
  end
28
28
  end
29
29
  end
30
- end
30
+ end
@@ -23,4 +23,4 @@ module FlowChat
23
23
  end
24
24
  end
25
25
  end
26
- end
26
+ end