flow_chat 0.4.2 → 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 +55 -0
- data/examples/multi_tenant_whatsapp_controller.rb +5 -1
- data/examples/simulator_controller.rb +95 -0
- data/examples/whatsapp_controller.rb +92 -3
- data/lib/flow_chat/base_processor.rb +2 -1
- data/lib/flow_chat/config.rb +3 -1
- data/lib/flow_chat/simulator/controller.rb +34 -5
- data/lib/flow_chat/version.rb +1 -1
- data/lib/flow_chat/whatsapp/client.rb +3 -5
- data/lib/flow_chat/whatsapp/configuration.rb +13 -12
- data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +121 -9
- data/lib/flow_chat/whatsapp/template_manager.rb +3 -3
- metadata +4 -2
@@ -2,6 +2,9 @@ module FlowChat
|
|
2
2
|
module Simulator
|
3
3
|
module Controller
|
4
4
|
def flowchat_simulator
|
5
|
+
# Set simulator cookie for authentication
|
6
|
+
set_simulator_cookie
|
7
|
+
|
5
8
|
respond_to do |format|
|
6
9
|
format.html do
|
7
10
|
render inline: simulator_view_template, layout: false, locals: simulator_locals
|
@@ -27,7 +30,7 @@ module FlowChat
|
|
27
30
|
{
|
28
31
|
"ussd" => {
|
29
32
|
name: "USSD (Nalo)",
|
30
|
-
description: "
|
33
|
+
description: "USSD integration using Nalo",
|
31
34
|
processor_type: "ussd",
|
32
35
|
provider: "nalo",
|
33
36
|
endpoint: "/ussd",
|
@@ -39,8 +42,8 @@ module FlowChat
|
|
39
42
|
}
|
40
43
|
},
|
41
44
|
"whatsapp" => {
|
42
|
-
name: "WhatsApp",
|
43
|
-
description: "
|
45
|
+
name: "WhatsApp (Cloud API)",
|
46
|
+
description: "WhatsApp integration using Cloud API",
|
44
47
|
processor_type: "whatsapp",
|
45
48
|
provider: "cloud_api",
|
46
49
|
endpoint: "/whatsapp/webhook",
|
@@ -49,8 +52,6 @@ module FlowChat
|
|
49
52
|
settings: {
|
50
53
|
phone_number: default_phone_number,
|
51
54
|
contact_name: default_contact_name,
|
52
|
-
verify_token: "local_verify_token",
|
53
|
-
webhook_url: "http://localhost:3000/whatsapp/webhook"
|
54
55
|
}
|
55
56
|
}
|
56
57
|
}
|
@@ -73,6 +74,34 @@ module FlowChat
|
|
73
74
|
configurations: simulator_configurations
|
74
75
|
}
|
75
76
|
end
|
77
|
+
|
78
|
+
def set_simulator_cookie
|
79
|
+
# Get global simulator secret
|
80
|
+
simulator_secret = FlowChat::Config.simulator_secret
|
81
|
+
|
82
|
+
unless simulator_secret && !simulator_secret.empty?
|
83
|
+
raise StandardError, "Simulator secret not configured. Please set FlowChat::Config.simulator_secret to enable simulator mode."
|
84
|
+
end
|
85
|
+
|
86
|
+
# Generate timestamp-based signed cookie
|
87
|
+
timestamp = Time.now.to_i
|
88
|
+
message = "simulator:#{timestamp}"
|
89
|
+
signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), simulator_secret, message)
|
90
|
+
|
91
|
+
cookie_value = "#{timestamp}:#{signature}"
|
92
|
+
|
93
|
+
# Set secure cookie (valid for 24 hours)
|
94
|
+
cookies[:flowchat_simulator] = {
|
95
|
+
value: cookie_value,
|
96
|
+
expires: 24.hours.from_now,
|
97
|
+
secure: request.ssl?, # Only send over HTTPS in production
|
98
|
+
httponly: true, # Prevent XSS access
|
99
|
+
same_site: :lax # CSRF protection while allowing normal navigation
|
100
|
+
}
|
101
|
+
rescue => e
|
102
|
+
Rails.logger.warn "Failed to set simulator cookie: #{e.message}"
|
103
|
+
raise e # Re-raise the exception so it's not silently ignored
|
104
|
+
end
|
76
105
|
end
|
77
106
|
end
|
78
107
|
end
|
data/lib/flow_chat/version.rb
CHANGED
@@ -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
|
@@ -134,7 +132,7 @@ 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
|
|
@@ -289,7 +287,7 @@ module FlowChat
|
|
289
287
|
# @param media_id [String] Media ID from WhatsApp
|
290
288
|
# @return [String] Media URL or nil on error
|
291
289
|
def get_media_url(media_id)
|
292
|
-
uri = URI("#{
|
290
|
+
uri = URI("#{FlowChat::Config.whatsapp.api_base_url}/#{media_id}")
|
293
291
|
http = Net::HTTP.new(uri.host, uri.port)
|
294
292
|
http.use_ssl = true
|
295
293
|
|
@@ -389,7 +387,7 @@ module FlowChat
|
|
389
387
|
# @param message_data [Hash] Message payload
|
390
388
|
# @return [Hash] API response or nil on error
|
391
389
|
def send_message_payload(message_data)
|
392
|
-
uri = URI("#{
|
390
|
+
uri = URI("#{FlowChat::Config.whatsapp.api_base_url}/#{@config.phone_number_id}/messages")
|
393
391
|
http = Net::HTTP.new(uri.host, uri.port)
|
394
392
|
http.use_ssl = true
|
395
393
|
|
@@ -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
|
@@ -32,8 +32,8 @@ 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
39
|
config.access_token = ENV["WHATSAPP_ACCESS_TOKEN"]
|
@@ -41,8 +41,8 @@ module FlowChat
|
|
41
41
|
config.verify_token = ENV["WHATSAPP_VERIFY_TOKEN"]
|
42
42
|
config.app_id = ENV["WHATSAPP_APP_ID"]
|
43
43
|
config.app_secret = ENV["WHATSAPP_APP_SECRET"]
|
44
|
-
config.webhook_url = ENV["WHATSAPP_WEBHOOK_URL"]
|
45
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
|
@@ -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
|
@@ -37,8 +39,8 @@ module FlowChat
|
|
37
39
|
private
|
38
40
|
|
39
41
|
def determine_message_handler(context)
|
40
|
-
# Check
|
41
|
-
if context["simulator_mode"]
|
42
|
+
# Check if simulator mode was already detected and set in context
|
43
|
+
if context["simulator_mode"]
|
42
44
|
return :simulator
|
43
45
|
end
|
44
46
|
|
@@ -61,15 +63,30 @@ module FlowChat
|
|
61
63
|
|
62
64
|
def handle_webhook(context)
|
63
65
|
controller = context.controller
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
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
|
68
79
|
context["simulator_mode"] = true
|
69
80
|
end
|
70
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
|
+
|
71
88
|
# Extract message data from WhatsApp webhook
|
72
|
-
entry = body.dig("entry", 0)
|
89
|
+
entry = @body.dig("entry", 0)
|
73
90
|
return controller.head :ok unless entry
|
74
91
|
|
75
92
|
changes = entry.dig("changes", 0)
|
@@ -116,6 +133,57 @@ module FlowChat
|
|
116
133
|
controller.head :ok
|
117
134
|
end
|
118
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
|
+
|
119
187
|
def extract_message_content(message, context)
|
120
188
|
case message["type"]
|
121
189
|
when "text"
|
@@ -205,6 +273,50 @@ module FlowChat
|
|
205
273
|
nil
|
206
274
|
end
|
207
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
|
314
|
+
end
|
315
|
+
end
|
316
|
+
|
317
|
+
def parse_request_body(request)
|
318
|
+
@body ||= JSON.parse(request.body.read)
|
319
|
+
end
|
208
320
|
end
|
209
321
|
end
|
210
322
|
end
|
@@ -86,7 +86,7 @@ module FlowChat
|
|
86
86
|
# Create a new template (requires approval from Meta)
|
87
87
|
def create_template(name:, category:, language: "en_US", components: [])
|
88
88
|
business_account_id = @config.business_account_id
|
89
|
-
uri = URI("
|
89
|
+
uri = URI("#{FlowChat::Config.whatsapp.api_base_url}/#{business_account_id}/message_templates")
|
90
90
|
|
91
91
|
template_data = {
|
92
92
|
name: name,
|
@@ -110,7 +110,7 @@ module FlowChat
|
|
110
110
|
# List all templates
|
111
111
|
def list_templates
|
112
112
|
business_account_id = @config.business_account_id
|
113
|
-
uri = URI("
|
113
|
+
uri = URI("#{FlowChat::Config.whatsapp.api_base_url}/#{business_account_id}/message_templates")
|
114
114
|
|
115
115
|
http = Net::HTTP.new(uri.host, uri.port)
|
116
116
|
http.use_ssl = true
|
@@ -124,7 +124,7 @@ module FlowChat
|
|
124
124
|
|
125
125
|
# Get template status
|
126
126
|
def template_status(template_id)
|
127
|
-
uri = URI("
|
127
|
+
uri = URI("#{FlowChat::Config.whatsapp.api_base_url}/#{template_id}")
|
128
128
|
|
129
129
|
http = Net::HTTP.new(uri.host, uri.port)
|
130
130
|
http.use_ssl = true
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: flow_chat
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.5.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Stefan Froelich
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-06-
|
11
|
+
date: 2025-06-05 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: zeitwerk
|
@@ -95,11 +95,13 @@ files:
|
|
95
95
|
- LICENSE.txt
|
96
96
|
- README.md
|
97
97
|
- Rakefile
|
98
|
+
- SECURITY.md
|
98
99
|
- bin/console
|
99
100
|
- bin/setup
|
100
101
|
- examples/initializer.rb
|
101
102
|
- examples/media_prompts_examples.rb
|
102
103
|
- examples/multi_tenant_whatsapp_controller.rb
|
104
|
+
- examples/simulator_controller.rb
|
103
105
|
- examples/ussd_controller.rb
|
104
106
|
- examples/whatsapp_controller.rb
|
105
107
|
- examples/whatsapp_media_examples.rb
|