flow_chat 0.8.2 → 0.9.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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/.cliff.toml +74 -0
  3. data/.github/workflows/ci.yml +2 -3
  4. data/.github/workflows/release.yml +56 -0
  5. data/.standard.yml +4 -0
  6. data/CHANGELOG.md +22 -0
  7. data/CLAUDE.md +327 -0
  8. data/CONTRIBUTING.md +134 -0
  9. data/Gemfile +1 -0
  10. data/README.md +290 -105
  11. data/Rakefile +5 -1
  12. data/SECURITY.md +42 -349
  13. data/docs/architecture.md +510 -0
  14. data/docs/async-background-processing.md +298 -0
  15. data/docs/configuration.md +556 -226
  16. data/docs/factory-pattern.md +355 -0
  17. data/docs/gateway-context-variables.md +171 -0
  18. data/docs/gateway-development.md +723 -0
  19. data/docs/getting-started.md +429 -0
  20. data/docs/instrumentation.md +264 -153
  21. data/docs/platforms/telegram.md +1013 -0
  22. data/docs/platforms/ussd.md +693 -0
  23. data/docs/platforms/whatsapp.md +1395 -0
  24. data/docs/testing.md +243 -365
  25. data/examples/custom_session_id_example.rb +119 -0
  26. data/examples/http_controller.rb +11 -11
  27. data/examples/intercom_configuration_example.rb +118 -0
  28. data/examples/intercom_controller.rb +194 -0
  29. data/examples/multi_tenant_whatsapp_controller.rb +4 -4
  30. data/examples/ussd_controller.rb +9 -9
  31. data/examples/whatsapp_controller.rb +2 -2
  32. data/flow_chat.gemspec +4 -0
  33. data/lib/flow_chat/{base_app.rb → app.rb} +16 -9
  34. data/lib/flow_chat/async_job.rb +176 -0
  35. data/lib/flow_chat/config.rb +10 -30
  36. data/lib/flow_chat/{base_executor.rb → executor.rb} +6 -11
  37. data/lib/flow_chat/factory.rb +94 -0
  38. data/lib/flow_chat/gateway_async_support.rb +106 -0
  39. data/lib/flow_chat/generic_async_job.rb +30 -0
  40. data/lib/flow_chat/http/gateway/simple.rb +81 -33
  41. data/lib/flow_chat/http/renderer.rb +3 -3
  42. data/lib/flow_chat/instrumentation/setup.rb +1 -1
  43. data/lib/flow_chat/instrumentation.rb +23 -0
  44. data/lib/flow_chat/intercom/client.rb +155 -0
  45. data/lib/flow_chat/intercom/configuration.rb +149 -0
  46. data/lib/flow_chat/intercom/gateway/intercom_api.rb +396 -0
  47. data/lib/flow_chat/intercom/renderer.rb +71 -0
  48. data/lib/flow_chat/phone_number_util.rb +37 -35
  49. data/lib/flow_chat/processor.rb +188 -0
  50. data/lib/flow_chat/renderers/markdown_support.rb +58 -0
  51. data/lib/flow_chat/session/middleware.rb +25 -9
  52. data/lib/flow_chat/telegram/client.rb +240 -0
  53. data/lib/flow_chat/telegram/configuration.rb +118 -0
  54. data/lib/flow_chat/telegram/gateway/bot_api.rb +300 -0
  55. data/lib/flow_chat/telegram/middleware/choice_mapper.rb +35 -0
  56. data/lib/flow_chat/telegram/renderer.rb +125 -0
  57. data/lib/flow_chat/telegram.rb +7 -0
  58. data/lib/flow_chat/ussd/gateway/nalo.rb +24 -4
  59. data/lib/flow_chat/ussd/middleware/pagination.rb +9 -5
  60. data/lib/flow_chat/ussd/renderer.rb +1 -1
  61. data/lib/flow_chat/version.rb +1 -1
  62. data/lib/flow_chat/whatsapp/client.rb +144 -13
  63. data/lib/flow_chat/whatsapp/configuration.rb +1 -1
  64. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +132 -96
  65. data/lib/flow_chat/whatsapp/id_generator.rb +124 -0
  66. data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +147 -0
  67. data/lib/flow_chat/whatsapp/renderer.rb +145 -11
  68. data/lib/flow_chat.rb +11 -1
  69. data/lib/tasks/release.rake +165 -0
  70. metadata +84 -25
  71. data/docs/flows.md +0 -320
  72. data/docs/http-gateway-protocol.md +0 -432
  73. data/docs/images/simulator.png +0 -0
  74. data/docs/media.md +0 -153
  75. data/docs/sessions.md +0 -433
  76. data/docs/ussd-setup.md +0 -322
  77. data/docs/whatsapp-setup.md +0 -162
  78. data/examples/whatsapp_message_job.rb +0 -113
  79. data/lib/flow_chat/base_processor.rb +0 -146
  80. data/lib/flow_chat/http/app.rb +0 -6
  81. data/lib/flow_chat/http/middleware/executor.rb +0 -24
  82. data/lib/flow_chat/http/processor.rb +0 -33
  83. data/lib/flow_chat/session/rails_session_store.rb +0 -68
  84. data/lib/flow_chat/ussd/app.rb +0 -6
  85. data/lib/flow_chat/ussd/gateway/nsano.rb +0 -96
  86. data/lib/flow_chat/ussd/middleware/executor.rb +0 -24
  87. data/lib/flow_chat/ussd/processor.rb +0 -39
  88. data/lib/flow_chat/whatsapp/app.rb +0 -29
  89. data/lib/flow_chat/whatsapp/middleware/executor.rb +0 -24
  90. data/lib/flow_chat/whatsapp/processor.rb +0 -32
  91. data/lib/flow_chat/whatsapp/send_job_support.rb +0 -79
@@ -19,10 +19,13 @@ module FlowChat
19
19
  # @param to [String] Phone number in E.164 format
20
20
  # @param response [Array] FlowChat response array [type, content, options]
21
21
  # @return [Hash] API response or nil on error
22
- def send_message(to, response)
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)}'" }
22
+ def send_message(to, prompt, choices: nil, media: nil)
23
+ FlowChat.logger.info { "WhatsApp::Client: Sending message to #{to}" }
24
+ FlowChat.logger.debug { "WhatsApp::Client: Message content: '#{prompt.to_s.truncate(100)}'" }
25
+
26
+ # Use renderer to convert to structured response
27
+ response = FlowChat::Whatsapp::Renderer.new(prompt, choices: choices, media: media).render
28
+ type, content, _options = response
26
29
 
27
30
  result = instrument(Events::MESSAGE_SENT, {
28
31
  to: to,
@@ -50,7 +53,7 @@ module FlowChat
50
53
  # @return [Hash] API response or nil on error
51
54
  def send_text(to, text)
52
55
  FlowChat.logger.debug { "WhatsApp::Client: Sending text message to #{to}" }
53
- send_message(to, [:text, text, {}])
56
+ send_message(to, text)
54
57
  end
55
58
 
56
59
  # Send interactive buttons
@@ -60,7 +63,8 @@ module FlowChat
60
63
  # @return [Hash] API response or nil on error
61
64
  def send_buttons(to, text, buttons)
62
65
  FlowChat.logger.debug { "WhatsApp::Client: Sending interactive buttons to #{to} with #{buttons.size} buttons" }
63
- send_message(to, [:interactive_buttons, text, {buttons: buttons}])
66
+ choices = buttons.each_with_object({}) { |button, hash| hash[button[:id]] = button[:title] }
67
+ send_message(to, text, choices: choices)
64
68
  end
65
69
 
66
70
  # Send interactive list
@@ -72,7 +76,9 @@ module FlowChat
72
76
  def send_list(to, text, sections, button_text = "Choose")
73
77
  total_items = sections.sum { |section| section[:rows]&.size || 0 }
74
78
  FlowChat.logger.debug { "WhatsApp::Client: Sending interactive list to #{to} with #{sections.size} sections, #{total_items} total items" }
75
- send_message(to, [:interactive_list, text, {sections: sections, button_text: button_text}])
79
+ choices = {}
80
+ sections.each { |section| section[:rows]&.each { |row| choices[row[:id]] = row[:title] } }
81
+ send_message(to, text, choices: choices)
76
82
  end
77
83
 
78
84
  # Send a template message
@@ -83,11 +89,13 @@ module FlowChat
83
89
  # @return [Hash] API response or nil on error
84
90
  def send_template(to, template_name, components = [], language = "en_US")
85
91
  FlowChat.logger.debug { "WhatsApp::Client: Sending template '#{template_name}' to #{to} in #{language}" }
86
- send_message(to, [:template, "", {
92
+ media = {
93
+ type: :template,
87
94
  template_name: template_name,
88
95
  components: components,
89
96
  language: language
90
- }])
97
+ }
98
+ send_message(to, "", media: media)
91
99
  end
92
100
 
93
101
  # Send image message
@@ -98,7 +106,12 @@ module FlowChat
98
106
  # @return [Hash] API response
99
107
  def send_image(to, image_url_or_id, caption = nil, mime_type = nil)
100
108
  FlowChat.logger.debug { "WhatsApp::Client: Sending image to #{to} - #{url?(image_url_or_id) ? "URL" : "Media ID"}" }
101
- send_media_message(to, :image, image_url_or_id, caption: caption, mime_type: mime_type)
109
+ media = if url?(image_url_or_id)
110
+ {type: :image, url: image_url_or_id}
111
+ else
112
+ {type: :image, id: image_url_or_id}
113
+ end
114
+ send_message(to, caption, media: media)
102
115
  end
103
116
 
104
117
  # Send document message
@@ -145,6 +158,41 @@ module FlowChat
145
158
  send_media_message(to, :sticker, sticker_url_or_id, mime_type: mime_type)
146
159
  end
147
160
 
161
+ # Mark an inbound message as read, optionally showing a typing indicator.
162
+ #
163
+ # WhatsApp Cloud API ties the typing indicator to the mark-as-read call:
164
+ # passing `typing: true` adds a `typing_indicator` field to the same
165
+ # request. The indicator auto-dismisses on the next outbound message or
166
+ # after ~25 seconds; there is no separate "stop typing" call.
167
+ #
168
+ # @param message_id [String] the inbound message id (wamid.*) to mark as read
169
+ # @param typing [Boolean] when true, also show a typing indicator
170
+ # @return [Hash, nil] parsed API response, or nil on failure
171
+ def mark_as_read(message_id, typing: false)
172
+ payload = {
173
+ messaging_product: "whatsapp",
174
+ status: "read",
175
+ message_id: message_id
176
+ }
177
+ payload[:typing_indicator] = {type: "text"} if typing
178
+
179
+ send_read_receipt_payload(payload, message_id)
180
+ end
181
+
182
+ # Show a typing indicator in response to an inbound message.
183
+ #
184
+ # Convenience wrapper around `mark_as_read(message_id, typing: true)`.
185
+ # Note that WhatsApp ties the typing indicator to read-receipt delivery,
186
+ # so calling this also marks the message as read. There is no stop-typing
187
+ # call — the indicator auto-dismisses on the next outbound message or
188
+ # after ~25 seconds.
189
+ #
190
+ # @param message_id [String] the inbound message id (wamid.*)
191
+ # @return [Hash, nil] parsed API response, or nil on failure
192
+ def indicate_typing(message_id)
193
+ mark_as_read(message_id, typing: true)
194
+ end
195
+
148
196
  # Upload media file and return media ID
149
197
  # @param file_path_or_io [String, IO] File path or IO object
150
198
  # @param mime_type [String] MIME type of the file (required)
@@ -379,7 +427,7 @@ module FlowChat
379
427
  data = JSON.parse(response.body)
380
428
  data["url"]
381
429
  else
382
- Rails.logger.error "WhatsApp Media API error: #{response.body}"
430
+ FlowChat.logger.error { "WhatsApp::Client: Media API error: #{response.body}" }
383
431
  nil
384
432
  end
385
433
  end
@@ -403,7 +451,7 @@ module FlowChat
403
451
  if response.is_a?(Net::HTTPSuccess)
404
452
  response.body
405
453
  else
406
- Rails.logger.error "WhatsApp Media download error: #{response.body}"
454
+ FlowChat.logger.error { "WhatsApp::Client: Media download error: #{response.body}" }
407
455
  nil
408
456
  end
409
457
  end
@@ -420,7 +468,7 @@ module FlowChat
420
468
  response = http.head(uri.path)
421
469
  response["content-type"]
422
470
  rescue => e
423
- Rails.logger.warn "Could not detect MIME type for #{url}: #{e.message}"
471
+ FlowChat.logger.warn { "WhatsApp::Client: Could not detect MIME type for #{url}: #{e.message}" }
424
472
  nil
425
473
  end
426
474
 
@@ -489,6 +537,13 @@ module FlowChat
489
537
  result
490
538
  else
491
539
  FlowChat.logger.error { "WhatsApp::Client: API request failed - #{response.code}: #{response.body}" }
540
+ report_api_error(
541
+ "WhatsApp API request failed",
542
+ response_code: response.code,
543
+ response_body: response.body,
544
+ recipient: to,
545
+ message_type: message_type
546
+ )
492
547
  nil
493
548
  end
494
549
  rescue Net::OpenTimeout, Net::ReadTimeout => network_error
@@ -497,9 +552,85 @@ module FlowChat
497
552
  raise network_error
498
553
  rescue => error
499
554
  FlowChat.logger.error { "WhatsApp::Client: API request exception: #{error.class.name}: #{error.message}" }
555
+ report_api_error(
556
+ "WhatsApp API request exception: #{error.class.name}",
557
+ error: error,
558
+ recipient: to,
559
+ message_type: message_type
560
+ )
500
561
  nil
501
562
  end
502
563
 
564
+ def send_read_receipt_payload(payload, message_id)
565
+ FlowChat.logger.debug { "WhatsApp::Client: Marking message #{message_id} as read (typing=#{payload.key?(:typing_indicator)})" }
566
+
567
+ uri = URI("#{FlowChat::Config.whatsapp.api_base_url}/#{@config.phone_number_id}/messages")
568
+ http = Net::HTTP.new(uri.host, uri.port)
569
+ http.use_ssl = true
570
+
571
+ request = Net::HTTP::Post.new(uri)
572
+ request["Authorization"] = "Bearer #{@config.access_token}"
573
+ request["Content-Type"] = "application/json"
574
+ request.body = payload.to_json
575
+
576
+ response = http.request(request)
577
+
578
+ if response.is_a?(Net::HTTPSuccess)
579
+ JSON.parse(response.body)
580
+ else
581
+ FlowChat.logger.error { "WhatsApp::Client: mark_as_read failed - #{response.code}: #{response.body}" }
582
+ report_api_error(
583
+ "WhatsApp mark_as_read failed",
584
+ response_code: response.code,
585
+ response_body: response.body,
586
+ message_type: "read_receipt"
587
+ )
588
+ nil
589
+ end
590
+ rescue Net::OpenTimeout, Net::ReadTimeout => network_error
591
+ FlowChat.logger.error { "WhatsApp::Client: Network timeout marking read: #{network_error.class.name}: #{network_error.message}" }
592
+ raise network_error
593
+ rescue => error
594
+ FlowChat.logger.error { "WhatsApp::Client: mark_as_read exception: #{error.class.name}: #{error.message}" }
595
+ report_api_error(
596
+ "WhatsApp mark_as_read exception: #{error.class.name}",
597
+ error: error,
598
+ message_type: "read_receipt"
599
+ )
600
+ nil
601
+ end
602
+
603
+ def report_api_error(message, response_code: nil, response_body: nil, error: nil, recipient: nil, message_type: nil)
604
+ error_details = parse_error_response(response_body)
605
+
606
+ FlowChat::Instrumentation.report_api_error(
607
+ message,
608
+ error: error,
609
+ platform: :whatsapp,
610
+ phone_number_id: @config.phone_number_id,
611
+ recipient: recipient,
612
+ message_type: message_type,
613
+ response_code: response_code,
614
+ **error_details
615
+ )
616
+ end
617
+
618
+ def parse_error_response(response_body)
619
+ return {} unless response_body
620
+
621
+ parsed = JSON.parse(response_body)
622
+ return {} unless parsed.is_a?(Hash) && parsed["error"]
623
+
624
+ {
625
+ error_type: parsed.dig("error", "type"),
626
+ error_code: parsed.dig("error", "code"),
627
+ error_subcode: parsed.dig("error", "error_subcode"),
628
+ error_message: parsed.dig("error", "message")
629
+ }.compact
630
+ rescue JSON::ParserError
631
+ {}
632
+ end
633
+
503
634
  def send_media_message(to, media_type, url_or_id, caption: nil, filename: nil, mime_type: nil)
504
635
  media_object = if url?(url_or_id)
505
636
  {link: url_or_id}
@@ -29,7 +29,7 @@ module FlowChat
29
29
 
30
30
  config = new(nil)
31
31
 
32
- if defined?(Rails) && Rails.application.credentials.whatsapp
32
+ if defined?(Rails) && Rails.respond_to?(:application) && Rails.application&.credentials&.whatsapp
33
33
  FlowChat.logger.debug { "WhatsApp::Configuration: Loading from Rails credentials" }
34
34
  credentials = Rails.application.credentials.whatsapp
35
35
  config.access_token = credentials[:access_token]
@@ -10,6 +10,7 @@ module FlowChat
10
10
  module Gateway
11
11
  class CloudApi
12
12
  include FlowChat::Instrumentation
13
+ include FlowChat::GatewayAsyncSupport
13
14
 
14
15
  attr_reader :context
15
16
 
@@ -24,48 +25,59 @@ module FlowChat
24
25
 
25
26
  def call(context)
26
27
  @context = context
27
- controller = context.controller
28
- request = controller.request
28
+ @controller = context.controller
29
+ request = @controller.request
29
30
 
30
31
  FlowChat.logger.debug { "CloudApi: Processing #{request.request_method} request to #{request.path}" }
31
32
 
32
- # Handle webhook verification
33
- if request.get? && request.params["hub.mode"] == "subscribe"
34
- FlowChat.logger.info { "CloudApi: Handling webhook verification request" }
35
- return handle_verification(context)
33
+ # Skip webhook-specific handling in background mode
34
+ unless in_background?
35
+ # Handle webhook verification
36
+ if request.get? && request.params["hub.mode"] == "subscribe"
37
+ FlowChat.logger.info { "CloudApi: Handling webhook verification request" }
38
+ return handle_verification(context)
39
+ end
36
40
  end
37
41
 
38
42
  # Handle webhook messages
39
43
  if request.post?
40
- FlowChat.logger.info { "CloudApi: Handling webhook message" }
44
+ FlowChat.logger.info { "CloudApi: Handling webhook message (background: #{in_background?})" }
41
45
  return handle_webhook(context)
42
46
  end
43
47
 
44
48
  FlowChat.logger.warn { "CloudApi: Invalid request method or parameters - returning bad request" }
45
- controller.head :bad_request
49
+ @controller.head :bad_request
46
50
  end
47
51
 
48
52
  # Expose client for out-of-band messaging
49
53
  attr_reader :client
50
54
 
55
+ # Configure WhatsApp-specific middleware stack
56
+ def self.configure_middleware_stack(builder, custom_middleware)
57
+ FlowChat.logger.debug { "CloudApi: Configuring WhatsApp middleware stack" }
58
+
59
+ builder.use custom_middleware
60
+ FlowChat.logger.debug { "CloudApi: Added custom middleware" }
61
+
62
+ builder.use FlowChat::Whatsapp::Middleware::ChoiceMapper
63
+ FlowChat.logger.debug { "CloudApi: Added Whatsapp::Middleware::ChoiceMapper" }
64
+ end
65
+
51
66
  private
52
67
 
53
68
  def determine_message_handler(context)
54
- # Check if simulator mode was already detected and set in context
69
+ # Use simulator mode if enabled, otherwise always use inline
55
70
  if context["simulator_mode"]
56
71
  FlowChat.logger.debug { "CloudApi: Using simulator message handler" }
57
- return :simulator
72
+ :simulator
73
+ else
74
+ FlowChat.logger.debug { "CloudApi: Using inline message handler" }
75
+ :inline
58
76
  end
59
-
60
- # Use global WhatsApp configuration
61
- mode = FlowChat::Config.whatsapp.message_handling_mode
62
- FlowChat.logger.debug { "CloudApi: Using #{mode} message handling mode" }
63
- mode
64
77
  end
65
78
 
66
79
  def handle_verification(context)
67
- controller = context.controller
68
- params = controller.request.params
80
+ params = @controller.request.params
69
81
 
70
82
  verify_token = @config.verify_token
71
83
  provided_token = params["hub.verify_token"]
@@ -80,7 +92,7 @@ module FlowChat
80
92
  platform: :whatsapp
81
93
  })
82
94
 
83
- controller.render plain: challenge
95
+ @controller.render plain: challenge
84
96
  else
85
97
  # Use instrumentation for webhook verification failure
86
98
  instrument(Events::WEBHOOK_FAILED, {
@@ -88,20 +100,18 @@ module FlowChat
88
100
  platform: :whatsapp
89
101
  })
90
102
 
91
- controller.head :forbidden
103
+ @controller.head :forbidden
92
104
  end
93
105
  end
94
106
 
95
107
  def handle_webhook(context)
96
- controller = context.controller
97
-
98
108
  # Parse body
99
109
  begin
100
- parse_request_body(controller.request)
110
+ parse_request_body(@controller.request)
101
111
  FlowChat.logger.debug { "CloudApi: Successfully parsed webhook request body" }
102
112
  rescue JSON::ParserError => e
103
113
  FlowChat.logger.error { "CloudApi: Failed to parse webhook body: #{e.message}" }
104
- return controller.head :bad_request
114
+ return @controller.head :bad_request
105
115
  end
106
116
 
107
117
  # Check for simulator mode parameter in request (before validation)
@@ -112,10 +122,11 @@ module FlowChat
112
122
  context["simulator_mode"] = true
113
123
  end
114
124
 
115
- # Validate webhook signature for security (skip for simulator mode)
116
- unless is_simulator_mode || valid_webhook_signature?(controller.request)
117
- FlowChat.logger.warn { "CloudApi: Invalid webhook signature received - rejecting request" }
118
- return controller.head :unauthorized
125
+ # Validate webhook signature for security (skip for simulator mode and background)
126
+ # Return 200 OK even for invalid signatures to prevent WhatsApp from retrying
127
+ unless in_background? || is_simulator_mode || valid_webhook_signature?(@controller.request)
128
+ FlowChat.logger.warn { "CloudApi: Invalid webhook signature - dropping request" }
129
+ return @controller.head :ok
119
130
  end
120
131
 
121
132
  FlowChat.logger.debug { "CloudApi: Webhook signature validation passed" }
@@ -124,19 +135,19 @@ module FlowChat
124
135
  entry = @body.dig("entry", 0)
125
136
  unless entry
126
137
  FlowChat.logger.debug { "CloudApi: No entry found in webhook body - returning OK" }
127
- return controller.head :ok
138
+ return @controller.head :ok
128
139
  end
129
140
 
130
141
  changes = entry.dig("changes", 0)
131
142
  unless changes
132
143
  FlowChat.logger.debug { "CloudApi: No changes found in webhook entry - returning OK" }
133
- return controller.head :ok
144
+ return @controller.head :ok
134
145
  end
135
146
 
136
147
  value = changes["value"]
137
148
  unless value
138
149
  FlowChat.logger.debug { "CloudApi: No value found in webhook changes - returning OK" }
139
- return controller.head :ok
150
+ return @controller.head :ok
140
151
  end
141
152
 
142
153
  # Handle incoming messages
@@ -147,15 +158,28 @@ module FlowChat
147
158
  phone_number = FlowChat::PhoneNumberUtil.to_e164(message["from"])
148
159
  message_id = message["id"]
149
160
  contact_name = contact&.dig("profile", "name")
161
+ business_phone_number = value.dig("metadata", "display_phone_number")
162
+ business_phone_number_id = value.dig("metadata", "phone_number_id")
163
+
164
+ # Validate that webhook is for our configured phone number
165
+ if business_phone_number_id != @config.phone_number_id
166
+ FlowChat.logger.warn { "CloudApi: Webhook received for phone_number_id '#{business_phone_number_id}' but configured for '#{@config.phone_number_id}' - rejecting" }
167
+ return @controller.head :forbidden
168
+ end
150
169
 
151
170
  context["request.id"] = phone_number
171
+ context["request.user_id"] = phone_number
172
+ context["request.user_name"] = contact_name if contact_name
152
173
  context["request.msisdn"] = phone_number
153
- context["request.user_id"] = context["request.msisdn"]
154
174
  context["request.gateway"] = :whatsapp_cloud_api
155
175
  context["request.platform"] = :whatsapp
156
176
  context["request.message_id"] = message_id
157
- context["request.contact_name"] = contact_name
158
- context["request.timestamp"] = message["timestamp"]
177
+ context["request.timestamp"] = Time.current.iso8601
178
+ context["request.body"] = @body
179
+
180
+ context["whatsapp.business.phone_number"] = FlowChat::PhoneNumberUtil.to_e164(business_phone_number)
181
+ context["whatsapp.business.phone_number_id"] = business_phone_number_id
182
+ context["whatsapp.client"] = @client
159
183
 
160
184
  # Extract message content based on type
161
185
  extract_message_content!(message, context)
@@ -166,25 +190,30 @@ module FlowChat
166
190
  from: phone_number,
167
191
  message: context.input,
168
192
  message_type: message["type"],
169
- message_id: message_id,
193
+ message_id: message_id
170
194
  })
171
195
  end
172
196
 
173
-
174
197
  FlowChat.logger.debug { "CloudApi: Message content extracted - Type: #{message["type"]}, Input: '#{context.input}'" }
175
198
 
176
- # Determine message handling mode
177
- handler_mode = determine_message_handler(context)
178
-
179
- # Process the message based on handling mode
180
- case handler_mode
181
- when :inline
182
- handle_message_inline(context, controller)
183
- when :background
184
- handle_message_background(context, controller)
185
- when :simulator
186
- # Return early from simulator mode to preserve the JSON response
187
- return handle_message_simulator(context, controller)
199
+ # Determine routing: async enqueue, background execute, or inline
200
+ if should_enqueue_async?
201
+ # Webhook with async enabled → enqueue job and return immediately
202
+ enqueue_async_job
203
+ return @controller.head :ok
204
+ else
205
+ # Background OR inline → process message
206
+ # Determine message handling mode (simulator vs inline)
207
+ handler_mode = determine_message_handler(context)
208
+
209
+ # Process the message based on handling mode
210
+ case handler_mode
211
+ when :inline
212
+ handle_message_inline(context, @controller)
213
+ when :simulator
214
+ # Return early from simulator mode to preserve the JSON response
215
+ return handle_message_simulator(context, @controller)
216
+ end
188
217
  end
189
218
  end
190
219
 
@@ -195,7 +224,7 @@ module FlowChat
195
224
  FlowChat.logger.debug { "CloudApi: Status updates: #{statuses.inspect}" }
196
225
  end
197
226
 
198
- controller.head :ok
227
+ @controller.head :ok
199
228
  end
200
229
 
201
230
  # Validate webhook signature to ensure request comes from WhatsApp
@@ -269,7 +298,7 @@ module FlowChat
269
298
  case message_type
270
299
  when "text"
271
300
  content = message.dig("text", "body")
272
- context.input = content
301
+ context.input = content.presence || ""
273
302
  FlowChat.logger.debug { "CloudApi: Text message content: '#{content}'" }
274
303
  when "interactive"
275
304
  # Handle button/list replies
@@ -290,57 +319,57 @@ module FlowChat
290
319
  address: message.dig("location", "address")
291
320
  }
292
321
  context["request.location"] = location
293
- context.input = "$location$"
322
+ context.input = FlowChat::Input::LOCATION
294
323
  FlowChat.logger.debug { "CloudApi: Location received - Lat: #{location[:latitude]}, Lng: #{location[:longitude]}" }
295
- when "image", "document", "audio", "video"
324
+ when "image", "document", "audio", "video", "sticker"
325
+ media_data = message[message["type"]]
296
326
  context["request.media"] = {
297
- type: message["type"],
298
- id: message.dig(message["type"], "id"),
299
- mime_type: message.dig(message["type"], "mime_type"),
300
- caption: message.dig(message["type"], "caption")
327
+ type: message["type"].to_sym,
328
+ id: media_data["id"],
329
+ mime_type: media_data["mime_type"],
330
+ caption: media_data["caption"],
331
+ filename: media_data["filename"],
332
+ sha256: media_data["sha256"],
333
+ animated: media_data["animated"]
301
334
  }
302
- context.input = "$media$"
335
+ context.input = FlowChat::Input::MEDIA
336
+ FlowChat.logger.debug { "CloudApi: Media received - Type: #{message["type"]}, ID: #{media_data["id"]}" }
337
+ when "contacts"
338
+ # WhatsApp sends contacts as an array, take the first one
339
+ contact_data = message.dig("contacts", 0)
340
+ if contact_data
341
+ phones = contact_data.dig("phones") || []
342
+ context["request.contact"] = {
343
+ name: contact_data.dig("name", "formatted_name"),
344
+ first_name: contact_data.dig("name", "first_name"),
345
+ last_name: contact_data.dig("name", "last_name"),
346
+ phones: phones.map { |p| p["phone"] },
347
+ phone_number: phones.first&.dig("phone")
348
+ }
349
+ context.input = FlowChat::Input::CONTACT
350
+ FlowChat.logger.debug { "CloudApi: Contact received - Name: #{context["request.contact"][:name]}" }
351
+ end
303
352
  end
304
353
  end
305
354
 
306
355
  def handle_message_inline(context, controller)
307
356
  response = @app.call(context)
308
357
  if response
309
- _type, prompt, choices, media = response
310
- rendered_message = render_response(prompt, choices, media)
311
- result = @client.send_message(context["request.msisdn"], rendered_message)
358
+ type, prompt, choices, media = response
359
+ result = @client.send_message(context["request.msisdn"], prompt, choices: choices, media: media)
312
360
  context["whatsapp.message_result"] = result
313
- end
314
- end
315
361
 
316
- def handle_message_background(context, controller)
317
- # Process the flow synchronously (maintaining controller context)
318
- response = @app.call(context)
319
-
320
- if response
321
- _type, prompt, choices, media = response
322
- rendered_message = render_response(prompt, choices, media)
323
-
324
- # Queue only the response delivery asynchronously
325
- send_data = {
326
- msisdn: context["request.msisdn"],
327
- response: rendered_message,
328
- config_name: @config.name
329
- }
330
-
331
- # Get job class from configuration
332
- job_class_name = FlowChat::Config.whatsapp.background_job_class
333
-
334
- # Enqueue background job for sending only
335
- begin
336
- job_class = job_class_name.constantize
337
- job_class.perform_later(send_data)
338
- rescue NameError
339
- # Fallback to inline sending if no job system
340
- Rails.logger.warn "Background mode requested but no #{job_class_name} found. Falling back to inline sending."
341
- result = @client.send_message(context["request.msisdn"], rendered_message)
342
- context["whatsapp.message_result"] = result
343
- end
362
+ # Instrument message sent
363
+ instrument(Events::MESSAGE_SENT, {
364
+ to: context["request.msisdn"],
365
+ session_id: context["request.id"],
366
+ message: prompt,
367
+ message_type: (type == :prompt) ? "prompt" : "terminal",
368
+ gateway: :whatsapp_cloud_api,
369
+ platform: :whatsapp,
370
+ content_length: prompt.to_s.length,
371
+ timestamp: context["request.timestamp"]
372
+ })
344
373
  end
345
374
  end
346
375
 
@@ -348,12 +377,12 @@ module FlowChat
348
377
  response = @app.call(context)
349
378
 
350
379
  if response
351
- _type, prompt, choices, media = response
352
- rendered_message = render_response(prompt, choices, media)
380
+ _, prompt, choices, media = response
381
+ response_data = render_response(prompt, choices, media)
353
382
 
354
383
  # For simulator mode, return the response data in the HTTP response
355
384
  # instead of actually sending via WhatsApp API
356
- message_payload = @client.build_message_payload(rendered_message, context["request.msisdn"])
385
+ message_payload = @client.build_message_payload(response_data, context["request.msisdn"])
357
386
 
358
387
  simulator_response = {
359
388
  mode: "simulator",
@@ -361,7 +390,7 @@ module FlowChat
361
390
  would_send: message_payload,
362
391
  message_info: {
363
392
  to: context["request.msisdn"],
364
- contact_name: context["request.contact_name"],
393
+ contact_name: context["request.user_name"],
365
394
  timestamp: Time.now.iso8601
366
395
  }
367
396
  }
@@ -384,8 +413,7 @@ module FlowChat
384
413
  return false unless simulator_secret && !simulator_secret.empty?
385
414
 
386
415
  # Check for simulator cookie
387
- request = context.controller.request
388
- simulator_cookie = request.cookies["flowchat_simulator"]
416
+ simulator_cookie = @controller.request.cookies["flowchat_simulator"]
389
417
  return false unless simulator_cookie
390
418
 
391
419
  # Verify the cookie is a valid HMAC signature
@@ -412,7 +440,15 @@ module FlowChat
412
440
  end
413
441
 
414
442
  def parse_request_body(request)
415
- @body ||= JSON.parse(request.body.read)
443
+ return @body if @body
444
+
445
+ if request.body.nil?
446
+ FlowChat.logger.debug { "CloudApi: Request body is nil, returning empty hash" }
447
+ @body = {}
448
+ else
449
+ request.body.rewind if request.body.respond_to?(:rewind)
450
+ @body = JSON.parse(request.body.read)
451
+ end
416
452
  end
417
453
 
418
454
  def render_response(prompt, choices, media)