flow_chat 0.6.1 → 0.8.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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +44 -0
- data/.gitignore +2 -1
- data/README.md +85 -1229
- data/docs/configuration.md +360 -0
- data/docs/flows.md +320 -0
- data/docs/images/simulator.png +0 -0
- data/docs/instrumentation.md +216 -0
- data/docs/media.md +153 -0
- data/docs/sessions.md +433 -0
- data/docs/testing.md +475 -0
- data/docs/ussd-setup.md +322 -0
- data/docs/whatsapp-setup.md +162 -0
- data/examples/multi_tenant_whatsapp_controller.rb +9 -37
- data/examples/simulator_controller.rb +13 -22
- data/examples/ussd_controller.rb +41 -41
- data/examples/whatsapp_controller.rb +32 -125
- data/examples/whatsapp_media_examples.rb +68 -336
- data/examples/whatsapp_message_job.rb +5 -3
- data/flow_chat.gemspec +6 -2
- data/lib/flow_chat/base_processor.rb +79 -2
- data/lib/flow_chat/config.rb +31 -5
- data/lib/flow_chat/context.rb +13 -1
- data/lib/flow_chat/instrumentation/log_subscriber.rb +176 -0
- data/lib/flow_chat/instrumentation/metrics_collector.rb +197 -0
- data/lib/flow_chat/instrumentation/setup.rb +155 -0
- data/lib/flow_chat/instrumentation.rb +70 -0
- data/lib/flow_chat/prompt.rb +20 -20
- data/lib/flow_chat/session/cache_session_store.rb +73 -7
- data/lib/flow_chat/session/middleware.rb +130 -12
- data/lib/flow_chat/session/rails_session_store.rb +36 -1
- data/lib/flow_chat/simulator/controller.rb +8 -8
- data/lib/flow_chat/simulator/views/simulator.html.erb +5 -5
- data/lib/flow_chat/ussd/gateway/nalo.rb +31 -0
- data/lib/flow_chat/ussd/gateway/nsano.rb +36 -2
- data/lib/flow_chat/ussd/middleware/choice_mapper.rb +109 -0
- data/lib/flow_chat/ussd/middleware/executor.rb +24 -2
- data/lib/flow_chat/ussd/middleware/pagination.rb +87 -7
- data/lib/flow_chat/ussd/processor.rb +16 -4
- data/lib/flow_chat/ussd/renderer.rb +1 -1
- data/lib/flow_chat/version.rb +1 -1
- data/lib/flow_chat/whatsapp/client.rb +99 -12
- data/lib/flow_chat/whatsapp/configuration.rb +35 -4
- data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +121 -34
- data/lib/flow_chat/whatsapp/middleware/executor.rb +24 -2
- data/lib/flow_chat/whatsapp/processor.rb +7 -1
- data/lib/flow_chat/whatsapp/renderer.rb +4 -9
- data/lib/flow_chat.rb +23 -0
- metadata +23 -12
- data/.travis.yml +0 -6
- data/app/controllers/demo_controller.rb +0 -101
- data/app/flow_chat/demo_restaurant_flow.rb +0 -889
- data/config/routes_demo.rb +0 -59
- data/examples/initializer.rb +0 -86
- data/examples/media_prompts_examples.rb +0 -27
- data/images/ussd_simulator.png +0 -0
- data/lib/flow_chat/ussd/middleware/resumable_session.rb +0 -39
@@ -7,8 +7,12 @@ require "securerandom"
|
|
7
7
|
module FlowChat
|
8
8
|
module Whatsapp
|
9
9
|
class Client
|
10
|
+
include FlowChat::Instrumentation
|
11
|
+
|
10
12
|
def initialize(config)
|
11
13
|
@config = config
|
14
|
+
FlowChat.logger.info { "WhatsApp::Client: Initialized WhatsApp client for phone_number_id: #{@config.phone_number_id}" }
|
15
|
+
FlowChat.logger.debug { "WhatsApp::Client: API base URL: #{FlowChat::Config.whatsapp.api_base_url}" }
|
12
16
|
end
|
13
17
|
|
14
18
|
# Send a message to a WhatsApp number
|
@@ -16,8 +20,28 @@ module FlowChat
|
|
16
20
|
# @param response [Array] FlowChat response array [type, content, options]
|
17
21
|
# @return [Hash] API response or nil on error
|
18
22
|
def send_message(to, response)
|
19
|
-
|
20
|
-
|
23
|
+
type, content, _ = response
|
24
|
+
FlowChat.logger.info { "WhatsApp::Client: Sending #{type} message to #{to}" }
|
25
|
+
FlowChat.logger.debug { "WhatsApp::Client: Message content: '#{content.to_s.truncate(100)}'" }
|
26
|
+
|
27
|
+
result = instrument(Events::MESSAGE_SENT, {
|
28
|
+
to: to,
|
29
|
+
message_type: type.to_s,
|
30
|
+
content_length: content.to_s.length,
|
31
|
+
platform: :whatsapp
|
32
|
+
}) do
|
33
|
+
message_data = build_message_payload(response, to)
|
34
|
+
send_message_payload(message_data)
|
35
|
+
end
|
36
|
+
|
37
|
+
if result
|
38
|
+
message_id = result.dig("messages", 0, "id")
|
39
|
+
FlowChat.logger.debug { "WhatsApp::Client: Message sent successfully to #{to}, message_id: #{message_id}" }
|
40
|
+
else
|
41
|
+
FlowChat.logger.error { "WhatsApp::Client: Failed to send message to #{to}" }
|
42
|
+
end
|
43
|
+
|
44
|
+
result
|
21
45
|
end
|
22
46
|
|
23
47
|
# Send a text message
|
@@ -25,6 +49,7 @@ module FlowChat
|
|
25
49
|
# @param text [String] Message text
|
26
50
|
# @return [Hash] API response or nil on error
|
27
51
|
def send_text(to, text)
|
52
|
+
FlowChat.logger.debug { "WhatsApp::Client: Sending text message to #{to}" }
|
28
53
|
send_message(to, [:text, text, {}])
|
29
54
|
end
|
30
55
|
|
@@ -34,6 +59,7 @@ module FlowChat
|
|
34
59
|
# @param buttons [Array] Array of button hashes with :id and :title
|
35
60
|
# @return [Hash] API response or nil on error
|
36
61
|
def send_buttons(to, text, buttons)
|
62
|
+
FlowChat.logger.debug { "WhatsApp::Client: Sending interactive buttons to #{to} with #{buttons.size} buttons" }
|
37
63
|
send_message(to, [:interactive_buttons, text, {buttons: buttons}])
|
38
64
|
end
|
39
65
|
|
@@ -44,6 +70,8 @@ module FlowChat
|
|
44
70
|
# @param button_text [String] Button text (default: "Choose")
|
45
71
|
# @return [Hash] API response or nil on error
|
46
72
|
def send_list(to, text, sections, button_text = "Choose")
|
73
|
+
total_items = sections.sum { |section| section[:rows]&.size || 0 }
|
74
|
+
FlowChat.logger.debug { "WhatsApp::Client: Sending interactive list to #{to} with #{sections.size} sections, #{total_items} total items" }
|
47
75
|
send_message(to, [:interactive_list, text, {sections: sections, button_text: button_text}])
|
48
76
|
end
|
49
77
|
|
@@ -54,6 +82,7 @@ module FlowChat
|
|
54
82
|
# @param language [String] Language code (default: "en_US")
|
55
83
|
# @return [Hash] API response or nil on error
|
56
84
|
def send_template(to, template_name, components = [], language = "en_US")
|
85
|
+
FlowChat.logger.debug { "WhatsApp::Client: Sending template '#{template_name}' to #{to} in #{language}" }
|
57
86
|
send_message(to, [:template, "", {
|
58
87
|
template_name: template_name,
|
59
88
|
components: components,
|
@@ -68,6 +97,7 @@ module FlowChat
|
|
68
97
|
# @param mime_type [String] Optional MIME type for URLs (e.g., 'image/jpeg')
|
69
98
|
# @return [Hash] API response
|
70
99
|
def send_image(to, image_url_or_id, caption = nil, mime_type = nil)
|
100
|
+
FlowChat.logger.debug { "WhatsApp::Client: Sending image to #{to} - #{url?(image_url_or_id) ? "URL" : "Media ID"}" }
|
71
101
|
send_media_message(to, :image, image_url_or_id, caption: caption, mime_type: mime_type)
|
72
102
|
end
|
73
103
|
|
@@ -80,6 +110,7 @@ module FlowChat
|
|
80
110
|
# @return [Hash] API response
|
81
111
|
def send_document(to, document_url_or_id, caption = nil, filename = nil, mime_type = nil)
|
82
112
|
filename ||= extract_filename_from_url(document_url_or_id) if url?(document_url_or_id)
|
113
|
+
FlowChat.logger.debug { "WhatsApp::Client: Sending document to #{to} - filename: #{filename}" }
|
83
114
|
send_media_message(to, :document, document_url_or_id, caption: caption, filename: filename, mime_type: mime_type)
|
84
115
|
end
|
85
116
|
|
@@ -90,6 +121,7 @@ module FlowChat
|
|
90
121
|
# @param mime_type [String] Optional MIME type for URLs (e.g., 'video/mp4')
|
91
122
|
# @return [Hash] API response
|
92
123
|
def send_video(to, video_url_or_id, caption = nil, mime_type = nil)
|
124
|
+
FlowChat.logger.debug { "WhatsApp::Client: Sending video to #{to}" }
|
93
125
|
send_media_message(to, :video, video_url_or_id, caption: caption, mime_type: mime_type)
|
94
126
|
end
|
95
127
|
|
@@ -99,6 +131,7 @@ module FlowChat
|
|
99
131
|
# @param mime_type [String] Optional MIME type for URLs (e.g., 'audio/mpeg')
|
100
132
|
# @return [Hash] API response
|
101
133
|
def send_audio(to, audio_url_or_id, mime_type = nil)
|
134
|
+
FlowChat.logger.debug { "WhatsApp::Client: Sending audio to #{to}" }
|
102
135
|
send_media_message(to, :audio, audio_url_or_id, mime_type: mime_type)
|
103
136
|
end
|
104
137
|
|
@@ -108,6 +141,7 @@ module FlowChat
|
|
108
141
|
# @param mime_type [String] Optional MIME type for URLs (e.g., 'image/webp')
|
109
142
|
# @return [Hash] API response
|
110
143
|
def send_sticker(to, sticker_url_or_id, mime_type = nil)
|
144
|
+
FlowChat.logger.debug { "WhatsApp::Client: Sending sticker to #{to}" }
|
111
145
|
send_media_message(to, :sticker, sticker_url_or_id, mime_type: mime_type)
|
112
146
|
end
|
113
147
|
|
@@ -118,17 +152,23 @@ module FlowChat
|
|
118
152
|
# @return [String] Media ID
|
119
153
|
# @raise [StandardError] If upload fails
|
120
154
|
def upload_media(file_path_or_io, mime_type, filename = nil)
|
155
|
+
FlowChat.logger.info { "WhatsApp::Client: Uploading media file - type: #{mime_type}, filename: #{filename}" }
|
156
|
+
|
121
157
|
raise ArgumentError, "mime_type is required" if mime_type.nil? || mime_type.empty?
|
122
158
|
|
159
|
+
file_size = nil
|
123
160
|
if file_path_or_io.is_a?(String)
|
124
161
|
# File path
|
125
162
|
raise ArgumentError, "File not found: #{file_path_or_io}" unless File.exist?(file_path_or_io)
|
126
163
|
filename ||= File.basename(file_path_or_io)
|
164
|
+
file_size = File.size(file_path_or_io)
|
165
|
+
FlowChat.logger.debug { "WhatsApp::Client: Uploading file from path: #{file_path_or_io} (#{file_size} bytes)" }
|
127
166
|
file = File.open(file_path_or_io, "rb")
|
128
167
|
else
|
129
168
|
# IO object
|
130
169
|
file = file_path_or_io
|
131
170
|
filename ||= "upload"
|
171
|
+
FlowChat.logger.debug { "WhatsApp::Client: Uploading file from IO object" }
|
132
172
|
end
|
133
173
|
|
134
174
|
# Upload directly via HTTP
|
@@ -136,6 +176,8 @@ module FlowChat
|
|
136
176
|
http = Net::HTTP.new(uri.host, uri.port)
|
137
177
|
http.use_ssl = true
|
138
178
|
|
179
|
+
FlowChat.logger.debug { "WhatsApp::Client: Uploading to #{uri}" }
|
180
|
+
|
139
181
|
# Prepare multipart form data
|
140
182
|
boundary = "----WebKitFormBoundary#{SecureRandom.hex(16)}"
|
141
183
|
|
@@ -165,15 +207,45 @@ module FlowChat
|
|
165
207
|
request["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
|
166
208
|
request.body = body
|
167
209
|
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
210
|
+
result = instrument(Events::MEDIA_UPLOAD, {
|
211
|
+
filename: filename,
|
212
|
+
mime_type: mime_type,
|
213
|
+
size: file_size,
|
214
|
+
platform: :whatsapp
|
215
|
+
}) do
|
216
|
+
response = http.request(request)
|
217
|
+
|
218
|
+
if response.is_a?(Net::HTTPSuccess)
|
219
|
+
data = JSON.parse(response.body)
|
220
|
+
media_id = data["id"]
|
221
|
+
if media_id
|
222
|
+
FlowChat.logger.info { "WhatsApp::Client: Media upload successful - media_id: #{media_id}" }
|
223
|
+
{success: true, media_id: media_id}
|
224
|
+
else
|
225
|
+
FlowChat.logger.error { "WhatsApp::Client: Media upload failed - no media_id in response: #{data}" }
|
226
|
+
raise StandardError, "Failed to upload media: #{data}"
|
227
|
+
end
|
228
|
+
else
|
229
|
+
FlowChat.logger.error { "WhatsApp::Client: Media upload error - #{response.code}: #{response.body}" }
|
230
|
+
raise StandardError, "Media upload failed: #{response.body}"
|
231
|
+
end
|
176
232
|
end
|
233
|
+
|
234
|
+
result[:media_id]
|
235
|
+
rescue => error
|
236
|
+
FlowChat.logger.error { "WhatsApp::Client: Media upload exception: #{error.class.name}: #{error.message}" }
|
237
|
+
|
238
|
+
# Instrument the error
|
239
|
+
instrument(Events::MEDIA_UPLOAD, {
|
240
|
+
filename: filename,
|
241
|
+
mime_type: mime_type,
|
242
|
+
size: file_size,
|
243
|
+
success: false,
|
244
|
+
error: error.message,
|
245
|
+
platform: :whatsapp
|
246
|
+
})
|
247
|
+
|
248
|
+
raise
|
177
249
|
ensure
|
178
250
|
file&.close if file_path_or_io.is_a?(String)
|
179
251
|
end
|
@@ -394,6 +466,11 @@ module FlowChat
|
|
394
466
|
# @param message_data [Hash] Message payload
|
395
467
|
# @return [Hash] API response or nil on error
|
396
468
|
def send_message_payload(message_data)
|
469
|
+
to = message_data[:to]
|
470
|
+
message_type = message_data[:type]
|
471
|
+
|
472
|
+
FlowChat.logger.debug { "WhatsApp::Client: Sending API request to #{to} - type: #{message_type}" }
|
473
|
+
|
397
474
|
uri = URI("#{FlowChat::Config.whatsapp.api_base_url}/#{@config.phone_number_id}/messages")
|
398
475
|
http = Net::HTTP.new(uri.host, uri.port)
|
399
476
|
http.use_ssl = true
|
@@ -403,14 +480,24 @@ module FlowChat
|
|
403
480
|
request["Content-Type"] = "application/json"
|
404
481
|
request.body = message_data.to_json
|
405
482
|
|
483
|
+
FlowChat.logger.debug { "WhatsApp::Client: Making HTTP request to WhatsApp API" }
|
406
484
|
response = http.request(request)
|
407
485
|
|
408
486
|
if response.is_a?(Net::HTTPSuccess)
|
409
|
-
JSON.parse(response.body)
|
487
|
+
result = JSON.parse(response.body)
|
488
|
+
FlowChat.logger.debug { "WhatsApp::Client: API request successful - response: #{result}" }
|
489
|
+
result
|
410
490
|
else
|
411
|
-
|
491
|
+
FlowChat.logger.error { "WhatsApp::Client: API request failed - #{response.code}: #{response.body}" }
|
412
492
|
nil
|
413
493
|
end
|
494
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => network_error
|
495
|
+
# Let network timeouts bubble up for proper error handling
|
496
|
+
FlowChat.logger.error { "WhatsApp::Client: Network timeout: #{network_error.class.name}: #{network_error.message}" }
|
497
|
+
raise network_error
|
498
|
+
rescue => error
|
499
|
+
FlowChat.logger.error { "WhatsApp::Client: API request exception: #{error.class.name}: #{error.message}" }
|
500
|
+
nil
|
414
501
|
end
|
415
502
|
|
416
503
|
def send_media_message(to, media_type, url_or_id, caption: nil, filename: nil, mime_type: nil)
|
@@ -18,14 +18,19 @@ module FlowChat
|
|
18
18
|
@business_account_id = nil
|
19
19
|
@skip_signature_validation = false
|
20
20
|
|
21
|
+
FlowChat.logger.debug { "WhatsApp::Configuration: Initialized configuration with name: #{name || "anonymous"}" }
|
22
|
+
|
21
23
|
register_as(name) if name.present?
|
22
24
|
end
|
23
25
|
|
24
26
|
# Load configuration from Rails credentials or environment variables
|
25
27
|
def self.from_credentials
|
28
|
+
FlowChat.logger.info { "WhatsApp::Configuration: Loading configuration from credentials/environment" }
|
29
|
+
|
26
30
|
config = new(nil)
|
27
31
|
|
28
32
|
if defined?(Rails) && Rails.application.credentials.whatsapp
|
33
|
+
FlowChat.logger.debug { "WhatsApp::Configuration: Loading from Rails credentials" }
|
29
34
|
credentials = Rails.application.credentials.whatsapp
|
30
35
|
config.access_token = credentials[:access_token]
|
31
36
|
config.phone_number_id = credentials[:phone_number_id]
|
@@ -35,6 +40,7 @@ module FlowChat
|
|
35
40
|
config.business_account_id = credentials[:business_account_id]
|
36
41
|
config.skip_signature_validation = credentials[:skip_signature_validation] || false
|
37
42
|
else
|
43
|
+
FlowChat.logger.debug { "WhatsApp::Configuration: Loading from environment variables" }
|
38
44
|
# Fallback to environment variables
|
39
45
|
config.access_token = ENV["WHATSAPP_ACCESS_TOKEN"]
|
40
46
|
config.phone_number_id = ENV["WHATSAPP_PHONE_NUMBER_ID"]
|
@@ -45,43 +51,68 @@ module FlowChat
|
|
45
51
|
config.skip_signature_validation = ENV["WHATSAPP_SKIP_SIGNATURE_VALIDATION"] == "true"
|
46
52
|
end
|
47
53
|
|
54
|
+
if config.valid?
|
55
|
+
FlowChat.logger.info { "WhatsApp::Configuration: Configuration loaded successfully - phone_number_id: #{config.phone_number_id}" }
|
56
|
+
else
|
57
|
+
FlowChat.logger.warn { "WhatsApp::Configuration: Incomplete configuration loaded - missing required fields" }
|
58
|
+
end
|
59
|
+
|
48
60
|
config
|
49
61
|
end
|
50
62
|
|
51
63
|
# Register a named configuration
|
52
64
|
def self.register(name, config)
|
65
|
+
FlowChat.logger.debug { "WhatsApp::Configuration: Registering configuration '#{name}'" }
|
53
66
|
@@configurations[name.to_sym] = config
|
54
67
|
end
|
55
68
|
|
56
69
|
# Get a named configuration
|
57
70
|
def self.get(name)
|
58
|
-
@@configurations[name.to_sym]
|
71
|
+
config = @@configurations[name.to_sym]
|
72
|
+
if config
|
73
|
+
FlowChat.logger.debug { "WhatsApp::Configuration: Retrieved configuration '#{name}'" }
|
74
|
+
config
|
75
|
+
else
|
76
|
+
FlowChat.logger.error { "WhatsApp::Configuration: Configuration '#{name}' not found" }
|
77
|
+
raise ArgumentError, "WhatsApp configuration '#{name}' not found"
|
78
|
+
end
|
59
79
|
end
|
60
80
|
|
61
81
|
# Check if a named configuration exists
|
62
82
|
def self.exists?(name)
|
63
|
-
@@configurations.key?(name.to_sym)
|
83
|
+
exists = @@configurations.key?(name.to_sym)
|
84
|
+
FlowChat.logger.debug { "WhatsApp::Configuration: Configuration '#{name}' exists: #{exists}" }
|
85
|
+
exists
|
64
86
|
end
|
65
87
|
|
66
88
|
# Get all configuration names
|
67
89
|
def self.configuration_names
|
68
|
-
@@configurations.keys
|
90
|
+
names = @@configurations.keys
|
91
|
+
FlowChat.logger.debug { "WhatsApp::Configuration: Available configurations: #{names}" }
|
92
|
+
names
|
69
93
|
end
|
70
94
|
|
71
95
|
# Clear all registered configurations (useful for testing)
|
72
96
|
def self.clear_all!
|
97
|
+
FlowChat.logger.debug { "WhatsApp::Configuration: Clearing all registered configurations" }
|
73
98
|
@@configurations.clear
|
74
99
|
end
|
75
100
|
|
76
101
|
# Register this configuration with a name
|
77
102
|
def register_as(name)
|
103
|
+
FlowChat.logger.debug { "WhatsApp::Configuration: Registering configuration as '#{name}'" }
|
78
104
|
@name = name.to_sym
|
79
105
|
self.class.register(@name, self)
|
80
106
|
self
|
81
107
|
end
|
82
108
|
|
83
109
|
def valid?
|
84
|
-
|
110
|
+
is_valid = access_token && !access_token.to_s.empty? &&
|
111
|
+
phone_number_id && !phone_number_id.to_s.empty? &&
|
112
|
+
verify_token && !verify_token.to_s.empty?
|
113
|
+
|
114
|
+
FlowChat.logger.debug { "WhatsApp::Configuration: Configuration valid: #{is_valid}" }
|
115
|
+
is_valid
|
85
116
|
end
|
86
117
|
|
87
118
|
# API endpoints
|
@@ -10,26 +10,39 @@ module FlowChat
|
|
10
10
|
|
11
11
|
module Gateway
|
12
12
|
class CloudApi
|
13
|
+
include FlowChat::Instrumentation
|
14
|
+
|
15
|
+
attr_reader :context
|
16
|
+
|
13
17
|
def initialize(app, config = nil)
|
14
18
|
@app = app
|
15
19
|
@config = config || FlowChat::Whatsapp::Configuration.from_credentials
|
16
20
|
@client = FlowChat::Whatsapp::Client.new(@config)
|
21
|
+
|
22
|
+
FlowChat.logger.info { "CloudApi: Initialized WhatsApp Cloud API gateway with phone_number_id: #{@config.phone_number_id}" }
|
23
|
+
FlowChat.logger.debug { "CloudApi: Gateway configuration - API base URL: #{FlowChat::Config.whatsapp.api_base_url}" }
|
17
24
|
end
|
18
25
|
|
19
26
|
def call(context)
|
27
|
+
@context = context
|
20
28
|
controller = context.controller
|
21
29
|
request = controller.request
|
22
30
|
|
31
|
+
FlowChat.logger.debug { "CloudApi: Processing #{request.request_method} request to #{request.path}" }
|
32
|
+
|
23
33
|
# Handle webhook verification
|
24
34
|
if request.get? && request.params["hub.mode"] == "subscribe"
|
35
|
+
FlowChat.logger.info { "CloudApi: Handling webhook verification request" }
|
25
36
|
return handle_verification(context)
|
26
37
|
end
|
27
38
|
|
28
39
|
# Handle webhook messages
|
29
40
|
if request.post?
|
41
|
+
FlowChat.logger.info { "CloudApi: Handling webhook message" }
|
30
42
|
return handle_webhook(context)
|
31
43
|
end
|
32
44
|
|
45
|
+
FlowChat.logger.warn { "CloudApi: Invalid request method or parameters - returning bad request" }
|
33
46
|
controller.head :bad_request
|
34
47
|
end
|
35
48
|
|
@@ -41,11 +54,14 @@ module FlowChat
|
|
41
54
|
def determine_message_handler(context)
|
42
55
|
# Check if simulator mode was already detected and set in context
|
43
56
|
if context["simulator_mode"]
|
57
|
+
FlowChat.logger.debug { "CloudApi: Using simulator message handler" }
|
44
58
|
return :simulator
|
45
59
|
end
|
46
60
|
|
47
61
|
# Use global WhatsApp configuration
|
48
|
-
FlowChat::Config.whatsapp.message_handling_mode
|
62
|
+
mode = FlowChat::Config.whatsapp.message_handling_mode
|
63
|
+
FlowChat.logger.debug { "CloudApi: Using #{mode} message handling mode" }
|
64
|
+
mode
|
49
65
|
end
|
50
66
|
|
51
67
|
def handle_verification(context)
|
@@ -53,63 +69,108 @@ module FlowChat
|
|
53
69
|
params = controller.request.params
|
54
70
|
|
55
71
|
verify_token = @config.verify_token
|
72
|
+
provided_token = params["hub.verify_token"]
|
73
|
+
challenge = params["hub.challenge"]
|
56
74
|
|
57
|
-
|
58
|
-
|
75
|
+
FlowChat.logger.debug { "CloudApi: Webhook verification - provided token matches: #{provided_token == verify_token}" }
|
76
|
+
|
77
|
+
if provided_token == verify_token
|
78
|
+
# Use instrumentation for webhook verification success
|
79
|
+
instrument(Events::WEBHOOK_VERIFIED, {
|
80
|
+
challenge: challenge,
|
81
|
+
platform: :whatsapp
|
82
|
+
})
|
83
|
+
|
84
|
+
controller.render plain: challenge
|
59
85
|
else
|
86
|
+
# Use instrumentation for webhook verification failure
|
87
|
+
instrument(Events::WEBHOOK_FAILED, {
|
88
|
+
reason: "Invalid verify token",
|
89
|
+
platform: :whatsapp
|
90
|
+
})
|
91
|
+
|
60
92
|
controller.head :forbidden
|
61
93
|
end
|
62
94
|
end
|
63
95
|
|
64
96
|
def handle_webhook(context)
|
65
97
|
controller = context.controller
|
66
|
-
|
98
|
+
|
67
99
|
# Parse body
|
68
100
|
begin
|
69
101
|
parse_request_body(controller.request)
|
102
|
+
FlowChat.logger.debug { "CloudApi: Successfully parsed webhook request body" }
|
70
103
|
rescue JSON::ParserError => e
|
71
|
-
|
104
|
+
FlowChat.logger.error { "CloudApi: Failed to parse webhook body: #{e.message}" }
|
72
105
|
return controller.head :bad_request
|
73
106
|
end
|
74
|
-
|
107
|
+
|
75
108
|
# Check for simulator mode parameter in request (before validation)
|
76
109
|
# But only enable if valid simulator token is provided
|
77
110
|
is_simulator_mode = simulate?(context)
|
78
111
|
if is_simulator_mode
|
112
|
+
FlowChat.logger.info { "CloudApi: Simulator mode enabled for this request" }
|
79
113
|
context["simulator_mode"] = true
|
80
114
|
end
|
81
115
|
|
82
116
|
# Validate webhook signature for security (skip for simulator mode)
|
83
117
|
unless is_simulator_mode || valid_webhook_signature?(controller.request)
|
84
|
-
|
118
|
+
FlowChat.logger.warn { "CloudApi: Invalid webhook signature received - rejecting request" }
|
85
119
|
return controller.head :unauthorized
|
86
120
|
end
|
87
121
|
|
122
|
+
FlowChat.logger.debug { "CloudApi: Webhook signature validation passed" }
|
123
|
+
|
88
124
|
# Extract message data from WhatsApp webhook
|
89
125
|
entry = @body.dig("entry", 0)
|
90
|
-
|
126
|
+
unless entry
|
127
|
+
FlowChat.logger.debug { "CloudApi: No entry found in webhook body - returning OK" }
|
128
|
+
return controller.head :ok
|
129
|
+
end
|
91
130
|
|
92
131
|
changes = entry.dig("changes", 0)
|
93
|
-
|
132
|
+
unless changes
|
133
|
+
FlowChat.logger.debug { "CloudApi: No changes found in webhook entry - returning OK" }
|
134
|
+
return controller.head :ok
|
135
|
+
end
|
94
136
|
|
95
137
|
value = changes["value"]
|
96
|
-
|
138
|
+
unless value
|
139
|
+
FlowChat.logger.debug { "CloudApi: No value found in webhook changes - returning OK" }
|
140
|
+
return controller.head :ok
|
141
|
+
end
|
97
142
|
|
98
143
|
# Handle incoming messages
|
99
144
|
if value["messages"]&.any?
|
100
145
|
message = value["messages"].first
|
101
146
|
contact = value["contacts"]&.first
|
102
147
|
|
103
|
-
|
148
|
+
phone_number = message["from"]
|
149
|
+
message_id = message["id"]
|
150
|
+
contact_name = contact&.dig("profile", "name")
|
151
|
+
|
152
|
+
# Use instrumentation for message received
|
153
|
+
instrument(Events::MESSAGE_RECEIVED, {
|
154
|
+
from: phone_number,
|
155
|
+
message: context.input,
|
156
|
+
message_type: message["type"],
|
157
|
+
message_id: message_id,
|
158
|
+
platform: :whatsapp
|
159
|
+
})
|
160
|
+
|
161
|
+
context["request.id"] = phone_number
|
104
162
|
context["request.gateway"] = :whatsapp_cloud_api
|
105
|
-
context["request.
|
106
|
-
context["request.
|
107
|
-
context["request.
|
163
|
+
context["request.platform"] = :whatsapp
|
164
|
+
context["request.message_id"] = message_id
|
165
|
+
context["request.msisdn"] = Phonelib.parse(phone_number).e164
|
166
|
+
context["request.contact_name"] = contact_name
|
108
167
|
context["request.timestamp"] = message["timestamp"]
|
109
168
|
|
110
169
|
# Extract message content based on type
|
111
170
|
extract_message_content(message, context)
|
112
171
|
|
172
|
+
FlowChat.logger.debug { "CloudApi: Message content extracted - Type: #{message["type"]}, Input: '#{context.input}'" }
|
173
|
+
|
113
174
|
# Determine message handling mode
|
114
175
|
handler_mode = determine_message_handler(context)
|
115
176
|
|
@@ -127,7 +188,9 @@ module FlowChat
|
|
127
188
|
|
128
189
|
# Handle message status updates
|
129
190
|
if value["statuses"]&.any?
|
130
|
-
|
191
|
+
statuses = value["statuses"]
|
192
|
+
FlowChat.logger.info { "CloudApi: Received #{statuses.size} status update(s)" }
|
193
|
+
FlowChat.logger.debug { "CloudApi: Status updates: #{statuses.inspect}" }
|
131
194
|
end
|
132
195
|
|
133
196
|
controller.head :ok
|
@@ -137,18 +200,23 @@ module FlowChat
|
|
137
200
|
def valid_webhook_signature?(request)
|
138
201
|
# Check if signature validation is explicitly disabled
|
139
202
|
if @config.skip_signature_validation
|
203
|
+
FlowChat.logger.debug { "CloudApi: Webhook signature validation is disabled" }
|
140
204
|
return true
|
141
205
|
end
|
142
206
|
|
143
207
|
# Require app_secret for signature validation
|
144
208
|
unless @config.app_secret && !@config.app_secret.empty?
|
145
|
-
|
146
|
-
|
147
|
-
|
209
|
+
error_msg = "WhatsApp app_secret is required for webhook signature validation. " \
|
210
|
+
"Either configure app_secret or set skip_signature_validation=true to explicitly disable validation."
|
211
|
+
FlowChat.logger.error { "CloudApi: #{error_msg}" }
|
212
|
+
raise FlowChat::Whatsapp::ConfigurationError, error_msg
|
148
213
|
end
|
149
214
|
|
150
215
|
signature_header = request.headers["X-Hub-Signature-256"]
|
151
|
-
|
216
|
+
unless signature_header
|
217
|
+
FlowChat.logger.warn { "CloudApi: No X-Hub-Signature-256 header found in request" }
|
218
|
+
return false
|
219
|
+
end
|
152
220
|
|
153
221
|
# Extract signature from header (format: "sha256=<signature>")
|
154
222
|
expected_signature = signature_header.sub("sha256=", "")
|
@@ -166,11 +234,19 @@ module FlowChat
|
|
166
234
|
)
|
167
235
|
|
168
236
|
# Compare signatures using secure comparison to prevent timing attacks
|
169
|
-
secure_compare(expected_signature, calculated_signature)
|
237
|
+
signature_valid = secure_compare(expected_signature, calculated_signature)
|
238
|
+
|
239
|
+
if signature_valid
|
240
|
+
FlowChat.logger.debug { "CloudApi: Webhook signature validation successful" }
|
241
|
+
else
|
242
|
+
FlowChat.logger.warn { "CloudApi: Webhook signature validation failed - signatures do not match" }
|
243
|
+
end
|
244
|
+
|
245
|
+
signature_valid
|
170
246
|
rescue FlowChat::Whatsapp::ConfigurationError
|
171
247
|
raise
|
172
248
|
rescue => e
|
173
|
-
|
249
|
+
FlowChat.logger.error { "CloudApi: Error validating webhook signature: #{e.class.name}: #{e.message}" }
|
174
250
|
false
|
175
251
|
end
|
176
252
|
|
@@ -185,24 +261,35 @@ module FlowChat
|
|
185
261
|
end
|
186
262
|
|
187
263
|
def extract_message_content(message, context)
|
188
|
-
|
264
|
+
message_type = message["type"]
|
265
|
+
FlowChat.logger.debug { "CloudApi: Extracting content from #{message_type} message" }
|
266
|
+
|
267
|
+
case message_type
|
189
268
|
when "text"
|
190
|
-
|
269
|
+
content = message.dig("text", "body")
|
270
|
+
context.input = content
|
271
|
+
FlowChat.logger.debug { "CloudApi: Text message content: '#{content}'" }
|
191
272
|
when "interactive"
|
192
273
|
# Handle button/list replies
|
193
274
|
if message.dig("interactive", "type") == "button_reply"
|
194
|
-
|
275
|
+
content = message.dig("interactive", "button_reply", "id")
|
276
|
+
context.input = content
|
277
|
+
FlowChat.logger.debug { "CloudApi: Button reply ID: '#{content}'" }
|
195
278
|
elsif message.dig("interactive", "type") == "list_reply"
|
196
|
-
|
279
|
+
content = message.dig("interactive", "list_reply", "id")
|
280
|
+
context.input = content
|
281
|
+
FlowChat.logger.debug { "CloudApi: List reply ID: '#{content}'" }
|
197
282
|
end
|
198
283
|
when "location"
|
199
|
-
|
284
|
+
location = {
|
200
285
|
latitude: message.dig("location", "latitude"),
|
201
286
|
longitude: message.dig("location", "longitude"),
|
202
287
|
name: message.dig("location", "name"),
|
203
288
|
address: message.dig("location", "address")
|
204
289
|
}
|
290
|
+
context["request.location"] = location
|
205
291
|
context.input = "$location$"
|
292
|
+
FlowChat.logger.debug { "CloudApi: Location received - Lat: #{location[:latitude]}, Lng: #{location[:longitude]}" }
|
206
293
|
when "image", "document", "audio", "video"
|
207
294
|
context["request.media"] = {
|
208
295
|
type: message["type"],
|
@@ -231,7 +318,7 @@ module FlowChat
|
|
231
318
|
if response
|
232
319
|
_type, prompt, choices, media = response
|
233
320
|
rendered_message = render_response(prompt, choices, media)
|
234
|
-
|
321
|
+
|
235
322
|
# Queue only the response delivery asynchronously
|
236
323
|
send_data = {
|
237
324
|
msisdn: context["request.msisdn"],
|
@@ -261,7 +348,7 @@ module FlowChat
|
|
261
348
|
if response
|
262
349
|
_type, prompt, choices, media = response
|
263
350
|
rendered_message = render_response(prompt, choices, media)
|
264
|
-
|
351
|
+
|
265
352
|
# For simulator mode, return the response data in the HTTP response
|
266
353
|
# instead of actually sending via WhatsApp API
|
267
354
|
message_payload = @client.build_message_payload(rendered_message, context["request.msisdn"])
|
@@ -285,7 +372,7 @@ module FlowChat
|
|
285
372
|
def simulate?(context)
|
286
373
|
# Check if simulator mode is enabled for this processor
|
287
374
|
return false unless context["enable_simulator"]
|
288
|
-
|
375
|
+
|
289
376
|
# Then check if simulator mode is requested and valid
|
290
377
|
@body.dig("simulator_mode") && valid_simulator_cookie?(context)
|
291
378
|
end
|
@@ -293,27 +380,27 @@ module FlowChat
|
|
293
380
|
def valid_simulator_cookie?(context)
|
294
381
|
simulator_secret = FlowChat::Config.simulator_secret
|
295
382
|
return false unless simulator_secret && !simulator_secret.empty?
|
296
|
-
|
383
|
+
|
297
384
|
# Check for simulator cookie
|
298
385
|
request = context.controller.request
|
299
386
|
simulator_cookie = request.cookies["flowchat_simulator"]
|
300
387
|
return false unless simulator_cookie
|
301
|
-
|
388
|
+
|
302
389
|
# Verify the cookie is a valid HMAC signature
|
303
390
|
# Cookie format: "timestamp:signature" where signature = HMAC(simulator_secret, "simulator:timestamp")
|
304
391
|
begin
|
305
392
|
timestamp_str, signature = simulator_cookie.split(":", 2)
|
306
393
|
return false unless timestamp_str && signature
|
307
|
-
|
394
|
+
|
308
395
|
# Check timestamp is recent (within 24 hours for reasonable session duration)
|
309
396
|
timestamp = timestamp_str.to_i
|
310
397
|
return false if timestamp <= 0
|
311
398
|
return false if (Time.now.to_i - timestamp).abs > 86400 # 24 hours
|
312
|
-
|
399
|
+
|
313
400
|
# Calculate expected signature
|
314
401
|
message = "simulator:#{timestamp_str}"
|
315
402
|
expected_signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), simulator_secret, message)
|
316
|
-
|
403
|
+
|
317
404
|
# Use secure comparison
|
318
405
|
secure_compare(signature, expected_signature)
|
319
406
|
rescue => e
|