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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +44 -0
  3. data/.gitignore +2 -1
  4. data/README.md +85 -1229
  5. data/docs/configuration.md +360 -0
  6. data/docs/flows.md +320 -0
  7. data/docs/images/simulator.png +0 -0
  8. data/docs/instrumentation.md +216 -0
  9. data/docs/media.md +153 -0
  10. data/docs/sessions.md +433 -0
  11. data/docs/testing.md +475 -0
  12. data/docs/ussd-setup.md +322 -0
  13. data/docs/whatsapp-setup.md +162 -0
  14. data/examples/multi_tenant_whatsapp_controller.rb +9 -37
  15. data/examples/simulator_controller.rb +13 -22
  16. data/examples/ussd_controller.rb +41 -41
  17. data/examples/whatsapp_controller.rb +32 -125
  18. data/examples/whatsapp_media_examples.rb +68 -336
  19. data/examples/whatsapp_message_job.rb +5 -3
  20. data/flow_chat.gemspec +6 -2
  21. data/lib/flow_chat/base_processor.rb +79 -2
  22. data/lib/flow_chat/config.rb +31 -5
  23. data/lib/flow_chat/context.rb +13 -1
  24. data/lib/flow_chat/instrumentation/log_subscriber.rb +176 -0
  25. data/lib/flow_chat/instrumentation/metrics_collector.rb +197 -0
  26. data/lib/flow_chat/instrumentation/setup.rb +155 -0
  27. data/lib/flow_chat/instrumentation.rb +70 -0
  28. data/lib/flow_chat/prompt.rb +20 -20
  29. data/lib/flow_chat/session/cache_session_store.rb +73 -7
  30. data/lib/flow_chat/session/middleware.rb +130 -12
  31. data/lib/flow_chat/session/rails_session_store.rb +36 -1
  32. data/lib/flow_chat/simulator/controller.rb +8 -8
  33. data/lib/flow_chat/simulator/views/simulator.html.erb +5 -5
  34. data/lib/flow_chat/ussd/gateway/nalo.rb +31 -0
  35. data/lib/flow_chat/ussd/gateway/nsano.rb +36 -2
  36. data/lib/flow_chat/ussd/middleware/choice_mapper.rb +109 -0
  37. data/lib/flow_chat/ussd/middleware/executor.rb +24 -2
  38. data/lib/flow_chat/ussd/middleware/pagination.rb +87 -7
  39. data/lib/flow_chat/ussd/processor.rb +16 -4
  40. data/lib/flow_chat/ussd/renderer.rb +1 -1
  41. data/lib/flow_chat/version.rb +1 -1
  42. data/lib/flow_chat/whatsapp/client.rb +99 -12
  43. data/lib/flow_chat/whatsapp/configuration.rb +35 -4
  44. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +121 -34
  45. data/lib/flow_chat/whatsapp/middleware/executor.rb +24 -2
  46. data/lib/flow_chat/whatsapp/processor.rb +7 -1
  47. data/lib/flow_chat/whatsapp/renderer.rb +4 -9
  48. data/lib/flow_chat.rb +23 -0
  49. metadata +23 -12
  50. data/.travis.yml +0 -6
  51. data/app/controllers/demo_controller.rb +0 -101
  52. data/app/flow_chat/demo_restaurant_flow.rb +0 -889
  53. data/config/routes_demo.rb +0 -59
  54. data/examples/initializer.rb +0 -86
  55. data/examples/media_prompts_examples.rb +0 -27
  56. data/images/ussd_simulator.png +0 -0
  57. 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
- message_data = build_message_payload(response, to)
20
- send_message_payload(message_data)
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
- response = http.request(request)
169
-
170
- if response.is_a?(Net::HTTPSuccess)
171
- data = JSON.parse(response.body)
172
- data["id"] || raise(StandardError, "Failed to upload media: #{data}")
173
- else
174
- Rails.logger.error "WhatsApp Media Upload error: #{response.body}"
175
- raise StandardError, "Media upload failed: #{response.body}"
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
- Rails.logger.error "WhatsApp API error: #{response.body}"
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] || raise(ArgumentError, "WhatsApp configuration '#{name}' not found")
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
- access_token && !access_token.to_s.empty? && phone_number_id && !phone_number_id.to_s.empty? && verify_token && !verify_token.to_s.empty?
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
- if params["hub.verify_token"] == verify_token
58
- controller.render plain: params["hub.challenge"]
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
- Rails.logger.warn "Failed to parse webhook body: #{e.message}"
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
- Rails.logger.warn "Invalid webhook signature received"
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
- return controller.head :ok unless entry
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
- return controller.head :ok unless changes
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
- return controller.head :ok unless value
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
- context["request.id"] = message["from"]
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.message_id"] = message["id"]
106
- context["request.msisdn"] = Phonelib.parse(message["from"]).e164
107
- context["request.contact_name"] = contact&.dig("profile", "name")
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
- Rails.logger.info "WhatsApp status update: #{value["statuses"]}"
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
- 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."
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
- return false unless signature_header
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
- Rails.logger.error "Error validating webhook signature: #{e.message}"
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
- case message["type"]
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
- context.input = message.dig("text", "body")
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
- context.input = message.dig("interactive", "button_reply", "id")
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
- context.input = message.dig("interactive", "list_reply", "id")
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
- context["request.location"] = {
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