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.
- checksums.yaml +4 -4
- data/README.md +418 -295
- data/SECURITY.md +365 -0
- data/examples/initializer.rb +56 -1
- data/examples/media_prompts_examples.rb +1 -2
- data/examples/multi_tenant_whatsapp_controller.rb +61 -57
- data/examples/simulator_controller.rb +95 -0
- data/examples/ussd_controller.rb +17 -11
- data/examples/whatsapp_controller.rb +103 -14
- data/examples/whatsapp_media_examples.rb +78 -80
- data/examples/whatsapp_message_job.rb +3 -3
- data/lib/flow_chat/base_processor.rb +3 -2
- data/lib/flow_chat/config.rb +6 -3
- data/lib/flow_chat/session/cache_session_store.rb +5 -5
- data/lib/flow_chat/simulator/controller.rb +34 -5
- data/lib/flow_chat/simulator/views/simulator.html.erb +287 -12
- data/lib/flow_chat/ussd/gateway/nsano.rb +1 -1
- data/lib/flow_chat/ussd/processor.rb +1 -1
- data/lib/flow_chat/ussd/prompt.rb +13 -13
- data/lib/flow_chat/version.rb +1 -1
- data/lib/flow_chat/whatsapp/app.rb +1 -1
- data/lib/flow_chat/whatsapp/client.rb +44 -50
- data/lib/flow_chat/whatsapp/configuration.rb +21 -20
- data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +129 -19
- data/lib/flow_chat/whatsapp/middleware/executor.rb +1 -1
- data/lib/flow_chat/whatsapp/processor.rb +1 -1
- data/lib/flow_chat/whatsapp/prompt.rb +27 -31
- data/lib/flow_chat/whatsapp/send_job_support.rb +7 -7
- data/lib/flow_chat/whatsapp/template_manager.rb +10 -10
- metadata +4 -2
@@ -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, {
|
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, {
|
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,
|
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("#{
|
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
|
-
|
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: {
|
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: {
|
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: {
|
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: {
|
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: {
|
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("#{
|
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
|
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 ==
|
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[
|
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?(
|
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("#{
|
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
|
-
|
418
|
-
|
419
|
-
|
420
|
-
|
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
|
430
|
-
to
|
431
|
-
type
|
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
|
-
|
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[
|
40
|
-
config.phone_number_id = ENV[
|
41
|
-
config.verify_token = ENV[
|
42
|
-
config.app_id = ENV[
|
43
|
-
config.app_secret = ENV[
|
44
|
-
config.
|
45
|
-
config.
|
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.
|
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
|
-
"
|
89
|
+
"#{FlowChat::Config.whatsapp.api_base_url}/#{phone_number_id}/messages"
|
94
90
|
end
|
95
91
|
|
96
92
|
def media_url(media_id)
|
97
|
-
"
|
93
|
+
"#{FlowChat::Config.whatsapp.api_base_url}/#{media_id}"
|
98
94
|
end
|
99
95
|
|
100
96
|
def phone_numbers_url
|
101
|
-
"
|
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
|
-
|
36
|
-
@client
|
37
|
-
end
|
37
|
+
attr_reader :client
|
38
38
|
|
39
39
|
private
|
40
40
|
|
41
41
|
def determine_message_handler(context)
|
42
|
-
# Check
|
43
|
-
if context["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
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
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
|
-
|
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
|