flow_chat 0.8.2 → 0.10.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 (147) 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/pages.yml +43 -0
  5. data/.github/workflows/release.yml +56 -0
  6. data/.standard.yml +4 -0
  7. data/CHANGELOG.md +48 -0
  8. data/CLAUDE.md +327 -0
  9. data/CONTRIBUTING.md +134 -0
  10. data/Gemfile +1 -0
  11. data/README.md +189 -133
  12. data/Rakefile +17 -2
  13. data/SECURITY.md +42 -349
  14. data/docs/architecture.md +83 -0
  15. data/docs/async-background-processing.md +64 -0
  16. data/docs/configuration.md +110 -287
  17. data/docs/factory-pattern.md +58 -0
  18. data/docs/gateway-context-variables.md +168 -0
  19. data/docs/gateway-development.md +159 -0
  20. data/docs/getting-started.md +90 -0
  21. data/docs/instrumentation.md +95 -175
  22. data/docs/platforms/instagram.md +278 -0
  23. data/docs/platforms/messenger.md +205 -0
  24. data/docs/platforms/telegram.md +109 -0
  25. data/docs/platforms/ussd.md +78 -0
  26. data/docs/platforms/whatsapp.md +147 -0
  27. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md +732 -0
  28. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json +58 -0
  29. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md +4064 -0
  30. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json +226 -0
  31. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md +972 -0
  32. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json +88 -0
  33. data/docs/superpowers/specs/2026-07-09-inbound-media-support-design.md +195 -0
  34. data/docs/superpowers/specs/2026-08-10-messenger-instagram-design.md +391 -0
  35. data/docs/testing.md +33 -426
  36. data/examples/custom_session_id_example.rb +119 -0
  37. data/examples/http_controller.rb +22 -20
  38. data/examples/intercom_configuration_example.rb +113 -0
  39. data/examples/intercom_controller.rb +182 -0
  40. data/examples/multi_tenant_whatsapp_controller.rb +63 -168
  41. data/examples/simulator_controller.rb +0 -1
  42. data/examples/ussd_controller.rb +88 -160
  43. data/examples/whatsapp_controller.rb +18 -17
  44. data/examples/whatsapp_media_examples.rb +27 -79
  45. data/flow_chat.gemspec +4 -0
  46. data/lib/flow_chat/app.rb +211 -0
  47. data/lib/flow_chat/async_job.rb +176 -0
  48. data/lib/flow_chat/choice_titles.rb +95 -0
  49. data/lib/flow_chat/config.rb +126 -23
  50. data/lib/flow_chat/delivery_error.rb +9 -0
  51. data/lib/flow_chat/{base_executor.rb → executor.rb} +6 -11
  52. data/lib/flow_chat/factory.rb +94 -0
  53. data/lib/flow_chat/gateway_async_support.rb +106 -0
  54. data/lib/flow_chat/generic_async_job.rb +30 -0
  55. data/lib/flow_chat/http/configuration_error.rb +9 -0
  56. data/lib/flow_chat/http/gateway/simple.rb +104 -36
  57. data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
  58. data/lib/flow_chat/http/renderer.rb +3 -3
  59. data/lib/flow_chat/input.rb +86 -0
  60. data/lib/flow_chat/instagram/client.rb +32 -0
  61. data/lib/flow_chat/instagram/configuration.rb +147 -0
  62. data/lib/flow_chat/instagram/configuration_error.rb +7 -0
  63. data/lib/flow_chat/instagram/gateway/send_api.rb +63 -0
  64. data/lib/flow_chat/instagram/middleware/choice_mapper.rb +22 -0
  65. data/lib/flow_chat/instagram/renderer.rb +23 -0
  66. data/lib/flow_chat/instrumentation/metrics_collector.rb +6 -1
  67. data/lib/flow_chat/instrumentation/setup.rb +1 -1
  68. data/lib/flow_chat/instrumentation.rb +182 -0
  69. data/lib/flow_chat/intercom/client.rb +161 -0
  70. data/lib/flow_chat/intercom/configuration.rb +102 -0
  71. data/lib/flow_chat/intercom/configuration_error.rb +9 -0
  72. data/lib/flow_chat/intercom/gateway/intercom_api.rb +420 -0
  73. data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
  74. data/lib/flow_chat/intercom/renderer.rb +123 -0
  75. data/lib/flow_chat/media.rb +121 -0
  76. data/lib/flow_chat/messenger/client.rb +264 -0
  77. data/lib/flow_chat/messenger/configuration.rb +103 -0
  78. data/lib/flow_chat/messenger/configuration_error.rb +9 -0
  79. data/lib/flow_chat/messenger/gateway/send_api.rb +42 -0
  80. data/lib/flow_chat/messenger/middleware/choice_mapper.rb +185 -0
  81. data/lib/flow_chat/messenger/renderer.rb +150 -0
  82. data/lib/flow_chat/meta/challenge.rb +24 -0
  83. data/lib/flow_chat/meta/choice_ladder.rb +37 -0
  84. data/lib/flow_chat/meta/configuration_error.rb +7 -0
  85. data/lib/flow_chat/meta/gateway_identity.rb +38 -0
  86. data/lib/flow_chat/meta/messaging_gateway.rb +468 -0
  87. data/lib/flow_chat/meta/signature.rb +30 -0
  88. data/lib/flow_chat/meta/signature_validation.rb +66 -0
  89. data/lib/flow_chat/meta/webhook_verification.rb +43 -0
  90. data/lib/flow_chat/named_configuration.rb +65 -0
  91. data/lib/flow_chat/phone_number_util.rb +37 -35
  92. data/lib/flow_chat/processor.rb +188 -0
  93. data/lib/flow_chat/prompt.rb +13 -16
  94. data/lib/flow_chat/renderers/markdown_support.rb +167 -0
  95. data/lib/flow_chat/security.rb +76 -0
  96. data/lib/flow_chat/session/middleware.rb +36 -11
  97. data/lib/flow_chat/simulator/controller.rb +31 -15
  98. data/lib/flow_chat/simulator/views/simulator.html.erb +184 -20
  99. data/lib/flow_chat/telegram/client.rb +283 -0
  100. data/lib/flow_chat/telegram/configuration.rb +78 -0
  101. data/lib/flow_chat/telegram/configuration_error.rb +9 -0
  102. data/lib/flow_chat/telegram/gateway/bot_api.rb +318 -0
  103. data/lib/flow_chat/telegram/middleware/choice_mapper.rb +96 -0
  104. data/lib/flow_chat/telegram/renderer.rb +133 -0
  105. data/lib/flow_chat/telegram.rb +7 -0
  106. data/lib/flow_chat/text_truncator.rb +75 -0
  107. data/lib/flow_chat/ussd/gateway/nalo.rb +24 -4
  108. data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
  109. data/lib/flow_chat/ussd/middleware/pagination.rb +9 -5
  110. data/lib/flow_chat/ussd/renderer.rb +1 -1
  111. data/lib/flow_chat/version.rb +1 -1
  112. data/lib/flow_chat/whatsapp/client.rb +158 -20
  113. data/lib/flow_chat/whatsapp/configuration.rb +13 -52
  114. data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
  115. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +335 -248
  116. data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +234 -0
  117. data/lib/flow_chat/whatsapp/renderer.rb +259 -64
  118. data/lib/flow_chat.rb +1 -1
  119. data/lib/tasks/release.rake +165 -0
  120. data/site/.nojekyll +0 -0
  121. data/site/.og-card.html +89 -0
  122. data/site/favicon.svg +6 -0
  123. data/site/index.html +209 -0
  124. data/site/og.png +0 -0
  125. metadata +132 -25
  126. data/docs/flows.md +0 -320
  127. data/docs/http-gateway-protocol.md +0 -432
  128. data/docs/images/simulator.png +0 -0
  129. data/docs/media.md +0 -153
  130. data/docs/sessions.md +0 -433
  131. data/docs/ussd-setup.md +0 -322
  132. data/docs/whatsapp-setup.md +0 -162
  133. data/examples/whatsapp_message_job.rb +0 -113
  134. data/lib/flow_chat/base_app.rb +0 -86
  135. data/lib/flow_chat/base_processor.rb +0 -146
  136. data/lib/flow_chat/http/app.rb +0 -6
  137. data/lib/flow_chat/http/middleware/executor.rb +0 -24
  138. data/lib/flow_chat/http/processor.rb +0 -33
  139. data/lib/flow_chat/session/rails_session_store.rb +0 -68
  140. data/lib/flow_chat/ussd/app.rb +0 -6
  141. data/lib/flow_chat/ussd/gateway/nsano.rb +0 -96
  142. data/lib/flow_chat/ussd/middleware/executor.rb +0 -24
  143. data/lib/flow_chat/ussd/processor.rb +0 -39
  144. data/lib/flow_chat/whatsapp/app.rb +0 -29
  145. data/lib/flow_chat/whatsapp/middleware/executor.rb +0 -24
  146. data/lib/flow_chat/whatsapp/processor.rb +0 -32
  147. data/lib/flow_chat/whatsapp/send_job_support.rb +0 -79
@@ -1,15 +1,14 @@
1
1
  require "net/http"
2
2
  require "json"
3
- require "openssl"
4
3
 
5
4
  module FlowChat
6
5
  module Whatsapp
7
- # Configuration-related errors
8
- class ConfigurationError < StandardError; end
9
-
10
6
  module Gateway
11
7
  class CloudApi
12
8
  include FlowChat::Instrumentation
9
+ include FlowChat::GatewayAsyncSupport
10
+ include FlowChat::Meta::SignatureValidation
11
+ include FlowChat::Meta::WebhookVerification
13
12
 
14
13
  attr_reader :context
15
14
 
@@ -24,84 +23,82 @@ module FlowChat
24
23
 
25
24
  def call(context)
26
25
  @context = context
27
- controller = context.controller
28
- request = controller.request
26
+ @controller = context.controller
27
+ request = @controller.request
29
28
 
30
29
  FlowChat.logger.debug { "CloudApi: Processing #{request.request_method} request to #{request.path}" }
31
30
 
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)
31
+ # Skip webhook-specific handling in background mode
32
+ unless in_background?
33
+ # Handle webhook verification
34
+ if request.get? && request.params["hub.mode"] == "subscribe"
35
+ FlowChat.logger.info { "CloudApi: Handling webhook verification request" }
36
+ return handle_verification(context)
37
+ end
36
38
  end
37
39
 
38
40
  # Handle webhook messages
39
41
  if request.post?
40
- FlowChat.logger.info { "CloudApi: Handling webhook message" }
42
+ FlowChat.logger.info { "CloudApi: Handling webhook message (background: #{in_background?})" }
41
43
  return handle_webhook(context)
42
44
  end
43
45
 
44
46
  FlowChat.logger.warn { "CloudApi: Invalid request method or parameters - returning bad request" }
45
- controller.head :bad_request
47
+ @controller.head :bad_request
46
48
  end
47
49
 
48
50
  # Expose client for out-of-band messaging
49
51
  attr_reader :client
50
52
 
51
- private
53
+ # Configure WhatsApp-specific middleware stack
54
+ def self.configure_middleware_stack(builder, custom_middleware)
55
+ FlowChat.logger.debug { "CloudApi: Configuring WhatsApp middleware stack" }
52
56
 
53
- def determine_message_handler(context)
54
- # Check if simulator mode was already detected and set in context
55
- if context["simulator_mode"]
56
- FlowChat.logger.debug { "CloudApi: Using simulator message handler" }
57
- return :simulator
58
- end
57
+ builder.use custom_middleware
58
+ FlowChat.logger.debug { "CloudApi: Added custom middleware" }
59
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
60
+ builder.use FlowChat::Whatsapp::Middleware::ChoiceMapper
61
+ FlowChat.logger.debug { "CloudApi: Added Whatsapp::Middleware::ChoiceMapper" }
64
62
  end
65
63
 
66
- def handle_verification(context)
67
- controller = context.controller
68
- params = controller.request.params
64
+ # What this gateway is, for the shared Meta:: modules. Public because
65
+ # FlowChat::Meta::GatewayIdentity declares them public: a gateway saying
66
+ # which platform it speaks for is not a secret, unlike how it validates a
67
+ # signature. Keeping them here rather than below `private` means every
68
+ # Meta gateway answers these the same way.
69
+ def platform
70
+ :whatsapp
71
+ end
69
72
 
70
- verify_token = @config.verify_token
71
- provided_token = params["hub.verify_token"]
72
- challenge = params["hub.challenge"]
73
+ def platform_label
74
+ "WhatsApp"
75
+ end
73
76
 
74
- FlowChat.logger.debug { "CloudApi: Webhook verification - provided token matches: #{provided_token == verify_token}" }
77
+ def configuration_error_class
78
+ FlowChat::Whatsapp::ConfigurationError
79
+ end
75
80
 
76
- if provided_token == verify_token
77
- # Use instrumentation for webhook verification success
78
- instrument(Events::WEBHOOK_VERIFIED, {
79
- challenge: challenge,
80
- platform: :whatsapp
81
- })
81
+ private
82
82
 
83
- controller.render plain: challenge
83
+ def determine_message_handler(context)
84
+ # Use simulator mode if enabled, otherwise always use inline
85
+ if context["simulator_mode"]
86
+ FlowChat.logger.debug { "CloudApi: Using simulator message handler" }
87
+ :simulator
84
88
  else
85
- # Use instrumentation for webhook verification failure
86
- instrument(Events::WEBHOOK_FAILED, {
87
- reason: "Invalid verify token",
88
- platform: :whatsapp
89
- })
90
-
91
- controller.head :forbidden
89
+ FlowChat.logger.debug { "CloudApi: Using inline message handler" }
90
+ :inline
92
91
  end
93
92
  end
94
93
 
95
94
  def handle_webhook(context)
96
- controller = context.controller
97
-
98
95
  # Parse body
99
96
  begin
100
- parse_request_body(controller.request)
97
+ parse_request_body(@controller.request)
101
98
  FlowChat.logger.debug { "CloudApi: Successfully parsed webhook request body" }
102
99
  rescue JSON::ParserError => e
103
100
  FlowChat.logger.error { "CloudApi: Failed to parse webhook body: #{e.message}" }
104
- return controller.head :bad_request
101
+ return @controller.head :bad_request
105
102
  end
106
103
 
107
104
  # Check for simulator mode parameter in request (before validation)
@@ -112,154 +109,241 @@ module FlowChat
112
109
  context["simulator_mode"] = true
113
110
  end
114
111
 
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
112
+ # Validate webhook signature for security (skip for simulator mode and background)
113
+ # Return 200 OK even for invalid signatures to prevent WhatsApp from retrying
114
+ unless in_background? || is_simulator_mode || valid_webhook_signature?(@controller.request)
115
+ FlowChat.logger.warn { "CloudApi: Invalid webhook signature - dropping request" }
116
+ return @controller.head :ok
119
117
  end
120
118
 
121
119
  FlowChat.logger.debug { "CloudApi: Webhook signature validation passed" }
122
120
 
123
- # Extract message data from WhatsApp webhook
124
- entry = @body.dig("entry", 0)
125
- unless entry
121
+ # A delivery can carry several accounts and several kinds of change at
122
+ # once, so every entry and every change gets looked at rather than only
123
+ # the first of each.
124
+ entries = @body["entry"]
125
+ unless entries.is_a?(Array) && entries.any?
126
126
  FlowChat.logger.debug { "CloudApi: No entry found in webhook body - returning OK" }
127
- return controller.head :ok
128
- end
129
-
130
- changes = entry.dig("changes", 0)
131
- unless changes
132
- FlowChat.logger.debug { "CloudApi: No changes found in webhook entry - returning OK" }
133
- return controller.head :ok
127
+ return @controller.head :ok
134
128
  end
135
129
 
136
- value = changes["value"]
137
- unless value
138
- FlowChat.logger.debug { "CloudApi: No value found in webhook changes - returning OK" }
139
- return controller.head :ok
140
- end
141
-
142
- # Handle incoming messages
143
- if value["messages"]&.any?
144
- message = value["messages"].first
145
- contact = value["contacts"]&.first
146
-
147
- phone_number = FlowChat::PhoneNumberUtil.to_e164(message["from"])
148
- message_id = message["id"]
149
- contact_name = contact&.dig("profile", "name")
150
-
151
- context["request.id"] = phone_number
152
- context["request.msisdn"] = phone_number
153
- context["request.user_id"] = context["request.msisdn"]
154
- context["request.gateway"] = :whatsapp_cloud_api
155
- context["request.platform"] = :whatsapp
156
- context["request.message_id"] = message_id
157
- context["request.contact_name"] = contact_name
158
- context["request.timestamp"] = message["timestamp"]
159
-
160
- # Extract message content based on type
161
- extract_message_content!(message, context)
162
-
163
- if context.input.present?
164
- # Use instrumentation for message received
165
- instrument(Events::MESSAGE_RECEIVED, {
166
- from: phone_number,
167
- message: context.input,
168
- message_type: message["type"],
169
- message_id: message_id,
170
- })
130
+ # Only one change per delivery can drive a flow, because only one can own
131
+ # the response to this request.
132
+ flow_ran = false
133
+
134
+ entries.each do |entry|
135
+ changes = entry["changes"]
136
+ next unless changes.is_a?(Array)
137
+
138
+ changes.each do |change|
139
+ value = change["value"]
140
+ next unless value.is_a?(Hash)
141
+
142
+ case webhook_field(change, value)
143
+ when "messages"
144
+ # There is no separate `statuses` field to subscribe to: Meta
145
+ # reports delivery status under `messages` as well, in a change
146
+ # carrying `statuses` and no `messages`. Handled before the flow
147
+ # slot is claimed, or a status arriving ahead of a message in the
148
+ # same delivery would spend the slot and drop the message.
149
+ #
150
+ # Foreground only. With async enabled the job re-enters this
151
+ # method on the same body, so publishing in both announced every
152
+ # status twice and delivery receipts double-counted. The request
153
+ # publishes them: it already holds the delivery, so a receipt is
154
+ # announced when it arrives rather than when the queue reaches
155
+ # it, and survives a job that is never picked up.
156
+ handle_statuses(value) if value["statuses"].present? && !in_background?
157
+
158
+ next if value["messages"].blank?
159
+
160
+ if flow_ran
161
+ FlowChat.logger.warn { "CloudApi: A second messages change arrived in the same delivery and was not processed" }
162
+ next
163
+ end
164
+ flow_ran = true
165
+
166
+ case handle_messages(context, value)
167
+ when :rejected then return @controller.head :forbidden
168
+ when :enqueued then return @controller.head :ok
169
+ when :rendered then return nil # simulator already wrote the response
170
+ end
171
+ when "statuses"
172
+ # Only reachable for a payload built without a field name, which
173
+ # our own fixtures do and Meta does not.
174
+ handle_statuses(value) unless in_background?
175
+ else
176
+ # Anything that is not a message or its delivery. Coexistence
177
+ # echoes, contact syncs, imported history, account bans, template
178
+ # approvals: all of it is the application's domain, so it is
179
+ # published rather than interpreted here.
180
+ handle_unmodelled_field(change["field"], value, entry["id"]) unless in_background?
181
+ end
171
182
  end
183
+ end
172
184
 
185
+ @controller.head :ok
186
+ end
173
187
 
174
- FlowChat.logger.debug { "CloudApi: Message content extracted - Type: #{message["type"]}, Input: '#{context.input}'" }
188
+ # Meta names the change in `field`. Older payloads, and the ones our own
189
+ # tests build, leave it out, so fall back to what the value carries.
190
+ def webhook_field(change, value)
191
+ return change["field"] if change["field"].present?
192
+ return "messages" if value["messages"].is_a?(Array) && value["messages"].any?
193
+ return "statuses" if value["statuses"].is_a?(Array) && value["statuses"].any?
175
194
 
176
- # Determine message handling mode
177
- handler_mode = determine_message_handler(context)
195
+ nil
196
+ end
178
197
 
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)
188
- end
198
+ # Returns what happened, so the caller can decide whether it still owns the
199
+ # response: :rejected, :enqueued, :rendered, :processed or :skipped.
200
+ def handle_messages(context, value)
201
+ message = value["messages"]&.first
202
+ return :skipped unless message
203
+
204
+ contact = value["contacts"]&.first
205
+
206
+ phone_number = FlowChat::PhoneNumberUtil.to_e164(message["from"])
207
+ message_id = message["id"]
208
+ contact_name = contact&.dig("profile", "name")
209
+ business_phone_number = value.dig("metadata", "display_phone_number")
210
+ business_phone_number_id = value.dig("metadata", "phone_number_id")
211
+
212
+ # Validate that webhook is for our configured phone number. Skipped
213
+ # in simulator mode rather than requiring the simulator to send an
214
+ # id that matches a real configuration: the simulator's whole
215
+ # point is running a turn with no live credentials at hand. Safe
216
+ # to skip, not a hole for real traffic, because
217
+ # context["simulator_mode"] is only ever true once simulate? has
218
+ # already checked the signed simulator cookie, above in
219
+ # handle_webhook.
220
+ if !context["simulator_mode"] && business_phone_number_id != @config.phone_number_id
221
+ FlowChat.logger.warn { "CloudApi: Webhook received for phone_number_id '#{business_phone_number_id}' but configured for '#{@config.phone_number_id}' - rejecting" }
222
+ return :rejected
189
223
  end
190
224
 
191
- # Handle message status updates
192
- if value["statuses"]&.any?
193
- statuses = value["statuses"]
194
- FlowChat.logger.info { "CloudApi: Received #{statuses.size} status update(s)" }
195
- FlowChat.logger.debug { "CloudApi: Status updates: #{statuses.inspect}" }
225
+ context["request.id"] = phone_number
226
+ context["request.user_id"] = phone_number
227
+ context["request.user_name"] = contact_name if contact_name
228
+ context["request.msisdn"] = phone_number
229
+ context["request.gateway"] = :whatsapp_cloud_api
230
+ context["request.platform"] = :whatsapp
231
+ context["request.message_id"] = message_id
232
+ context["request.timestamp"] = Time.current.iso8601
233
+ context["request.body"] = @body
234
+
235
+ context["whatsapp.business.phone_number"] = FlowChat::PhoneNumberUtil.to_e164(business_phone_number)
236
+ context["whatsapp.business.phone_number_id"] = business_phone_number_id
237
+ context["whatsapp.client"] = @client
238
+
239
+ # Extract message content based on type
240
+ extract_message_content!(message, context)
241
+
242
+ if inbound_message?(context)
243
+ # Use instrumentation for message received
244
+ instrument(Events::MESSAGE_RECEIVED, {
245
+ from: phone_number,
246
+ message: context.input,
247
+ message_type: message["type"],
248
+ message_id: message_id
249
+ })
196
250
  end
197
251
 
198
- controller.head :ok
199
- end
200
-
201
- # Validate webhook signature to ensure request comes from WhatsApp
202
- def valid_webhook_signature?(request)
203
- # Check if signature validation is explicitly disabled
204
- if @config.skip_signature_validation
205
- FlowChat.logger.debug { "CloudApi: Webhook signature validation is disabled" }
206
- return true
207
- end
252
+ FlowChat.logger.debug { "CloudApi: Message content extracted - Type: #{message["type"]}, Input: '#{context.input}'" }
208
253
 
209
- # Require app_secret for signature validation
210
- unless @config.app_secret && !@config.app_secret.empty?
211
- error_msg = "WhatsApp app_secret is required for webhook signature validation. " \
212
- "Either configure app_secret or set skip_signature_validation=true to explicitly disable validation."
213
- FlowChat.logger.error { "CloudApi: #{error_msg}" }
214
- raise FlowChat::Whatsapp::ConfigurationError, error_msg
254
+ # Determine routing: async enqueue, background execute, or inline
255
+ if should_enqueue_async?
256
+ # Webhook with async enabled enqueue job and return immediately
257
+ enqueue_async_job
258
+ return :enqueued
215
259
  end
216
260
 
217
- signature_header = request.headers["X-Hub-Signature-256"]
218
- unless signature_header
219
- FlowChat.logger.warn { "CloudApi: No X-Hub-Signature-256 header found in request" }
220
- return false
261
+ # Background OR inline → process message
262
+ case determine_message_handler(context)
263
+ when :inline
264
+ handle_message_inline(context, @controller)
265
+ :processed
266
+ when :simulator
267
+ handle_message_simulator(context, @controller)
268
+ :rendered
221
269
  end
270
+ end
222
271
 
223
- # Extract signature from header (format: "sha256=<signature>")
224
- expected_signature = signature_header.sub("sha256=", "")
225
-
226
- # Get raw request body
227
- request.body.rewind
228
- body = request.body.read
229
- request.body.rewind
230
-
231
- # Calculate HMAC signature
232
- calculated_signature = OpenSSL::HMAC.hexdigest(
233
- OpenSSL::Digest.new("sha256"),
234
- @config.app_secret,
235
- body
236
- )
237
-
238
- # Compare signatures using secure comparison to prevent timing attacks
239
- signature_valid = secure_compare(expected_signature, calculated_signature)
240
-
241
- if signature_valid
242
- FlowChat.logger.debug { "CloudApi: Webhook signature validation successful" }
243
- else
244
- FlowChat.logger.warn { "CloudApi: Webhook signature validation failed - signatures do not match" }
272
+ def handle_statuses(value)
273
+ statuses = value["statuses"]
274
+ return if statuses.blank?
275
+
276
+ FlowChat.logger.info { "CloudApi: Received #{statuses.size} status update(s)" }
277
+ FlowChat.logger.debug { "CloudApi: Status updates: #{statuses.inspect}" }
278
+
279
+ statuses.each do |status|
280
+ instrument(Events::MESSAGE_STATUS, {
281
+ platform: :whatsapp,
282
+ gateway: :whatsapp_cloud_api,
283
+ business_phone_number_id: value.dig("metadata", "phone_number_id"),
284
+ message_id: status["id"],
285
+ recipient: status["recipient_id"],
286
+ status: status["status"],
287
+ timestamp: status["timestamp"],
288
+ errors: status["errors"],
289
+ # Meta reports more about a delivery than a status and a time: what
290
+ # it billed the conversation as, and its own view of the window.
291
+ # None of it is this gem's business to interpret, and all of it is
292
+ # gone if the named keys are the only way through.
293
+ value: status
294
+ })
245
295
  end
246
-
247
- signature_valid
248
- rescue FlowChat::Whatsapp::ConfigurationError
249
- raise
250
- rescue => e
251
- FlowChat.logger.error { "CloudApi: Error validating webhook signature: #{e.class.name}: #{e.message}" }
252
- false
253
296
  end
254
297
 
255
- # Secure string comparison to prevent timing attacks
256
- def secure_compare(a, b)
257
- return false unless a.bytesize == b.bytesize
298
+ # Everything that is not a message or its delivery. Verified, named, and
299
+ # handed on whole for the application to make sense of.
300
+ #
301
+ # The account id comes from the entry rather than the value because a change
302
+ # about the account itself names no phone number: a ban, a review outcome, a
303
+ # template approval. Without it those arrive identifying nothing, and an
304
+ # application holding several businesses cannot tell whose they are.
305
+ def handle_unmodelled_field(field, value, business_account_id)
306
+ FlowChat.logger.info {
307
+ "CloudApi: Publishing webhook field '#{field}' (value keys: #{value.keys.join(", ")})"
308
+ }
309
+
310
+ payload = {
311
+ platform: :whatsapp,
312
+ gateway: :whatsapp_cloud_api,
313
+ field: field,
314
+ business_account_id: business_account_id,
315
+ business_phone_number: value.dig("metadata", "display_phone_number"),
316
+ business_phone_number_id: value.dig("metadata", "phone_number_id"),
317
+ value: value
318
+ }
319
+
320
+ origin = echo_origin(field, value)
321
+ payload[:echo_origin] = origin if origin
322
+
323
+ instrument(Events::WEBHOOK_RECEIVED, payload)
324
+ end
258
325
 
259
- l = a.unpack("C*")
260
- res = 0
261
- b.each_byte { |byte| res |= byte ^ l.shift }
262
- res == 0
326
+ # An echo reports a message sent on the thread by someone other than the
327
+ # user we are talking to. Which someone matters: a human replying from the
328
+ # business inbox usually means the application should stand the flow
329
+ # down, while our own send coming back means nothing at all. Only the
330
+ # app_id separates them, and only this gateway knows our own, so it is
331
+ # derived here rather than left for every subscriber to work out.
332
+ #
333
+ # Meta names the coexistence echo field "smb_message_echoes" and nests the
334
+ # echoes themselves under "message_echoes"; both are matched precisely
335
+ # rather than by a loose "echo" substring or "first array in the value",
336
+ # since a field this gateway does not otherwise interpret could carry
337
+ # other arrays under other names.
338
+ def echo_origin(field, value)
339
+ return nil unless field == "smb_message_echoes"
340
+
341
+ app_id = value["message_echoes"]&.first&.dig("app_id")
342
+
343
+ return :human_agent if app_id.blank?
344
+ return :self if app_id.to_s == @config.app_id.to_s
345
+
346
+ :other_app
263
347
  end
264
348
 
265
349
  def extract_message_content!(message, context)
@@ -269,7 +353,7 @@ module FlowChat
269
353
  case message_type
270
354
  when "text"
271
355
  content = message.dig("text", "body")
272
- context.input = content
356
+ context.input = content.presence || ""
273
357
  FlowChat.logger.debug { "CloudApi: Text message content: '#{content}'" }
274
358
  when "interactive"
275
359
  # Handle button/list replies
@@ -290,70 +374,96 @@ module FlowChat
290
374
  address: message.dig("location", "address")
291
375
  }
292
376
  context["request.location"] = location
293
- context.input = "$location$"
377
+ context.input = ""
294
378
  FlowChat.logger.debug { "CloudApi: Location received - Lat: #{location[:latitude]}, Lng: #{location[:longitude]}" }
295
- when "image", "document", "audio", "video"
379
+ when "image", "document", "audio", "video", "sticker"
380
+ media_data = message[message["type"]]
296
381
  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")
382
+ type: message["type"].to_sym,
383
+ id: media_data["id"],
384
+ mime_type: media_data["mime_type"],
385
+ caption: media_data["caption"],
386
+ filename: media_data["filename"],
387
+ sha256: media_data["sha256"],
388
+ animated: media_data["animated"]
301
389
  }
302
- context.input = "$media$"
390
+ # The caption (if any) is the turn's text; a text-less media message has blank input.
391
+ context.input = media_data["caption"].presence || ""
392
+ FlowChat.logger.debug { "CloudApi: Media received - Type: #{message["type"]}, ID: #{media_data["id"]}" }
393
+ when "contacts"
394
+ # WhatsApp sends contacts as an array, take the first one
395
+ contact_data = message.dig("contacts", 0)
396
+ if contact_data
397
+ phones = contact_data.dig("phones") || []
398
+ context["request.contact"] = {
399
+ name: contact_data.dig("name", "formatted_name"),
400
+ first_name: contact_data.dig("name", "first_name"),
401
+ last_name: contact_data.dig("name", "last_name"),
402
+ phones: phones.map { |p| p["phone"] },
403
+ phone_number: phones.first&.dig("phone")
404
+ }
405
+ context.input = ""
406
+ FlowChat.logger.debug { "CloudApi: Contact received - Name: #{context["request.contact"][:name]}" }
407
+ end
303
408
  end
304
409
  end
305
410
 
306
411
  def handle_message_inline(context, controller)
307
412
  response = @app.call(context)
308
413
  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)
414
+ type, prompt, choices, media = response
415
+ result = report_delivery_failure(
416
+ context,
417
+ to: context["request.msisdn"],
418
+ session_id: context["request.id"],
419
+ message: prompt,
420
+ message_type: (type == :prompt) ? "prompt" : "terminal",
421
+ gateway: :whatsapp_cloud_api,
422
+ platform: :whatsapp
423
+ ) do
424
+ @client.send_message(context["request.msisdn"], prompt, choices: choices, media: media)
425
+ end
312
426
  context["whatsapp.message_result"] = result
427
+
428
+ # report_delivery_failure already reported this; a nil result
429
+ # means the platform did not accept the message, and instrumenting
430
+ # MESSAGE_SENT anyway counted a delivery that never happened.
431
+ return unless result
432
+
433
+ # Instrument message sent
434
+ instrument(Events::MESSAGE_SENT, {
435
+ to: context["request.msisdn"],
436
+ session_id: context["request.id"],
437
+ message: prompt,
438
+ message_type: (type == :prompt) ? "prompt" : "terminal",
439
+ gateway: :whatsapp_cloud_api,
440
+ platform: :whatsapp,
441
+ content_length: prompt.to_s.length,
442
+ # What Meta called it, so this and message.status can be joined.
443
+ platform_message_id: platform_message_id_from(result),
444
+ duration_ms: context[FlowChat::Instrumentation::DELIVERY_DURATION_KEY],
445
+ timestamp: context["request.timestamp"]
446
+ })
313
447
  end
314
448
  end
315
449
 
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
- }
450
+ # Meta answers a send with the ids it assigned.
451
+ def platform_message_id_from(result)
452
+ return nil unless result.is_a?(Hash)
330
453
 
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
344
- end
454
+ result.dig("messages", 0, "id")
345
455
  end
346
456
 
347
457
  def handle_message_simulator(context, controller)
348
458
  response = @app.call(context)
349
459
 
350
460
  if response
351
- _type, prompt, choices, media = response
352
- rendered_message = render_response(prompt, choices, media)
461
+ _, prompt, choices, media = response
462
+ response_data = render_response(prompt, choices, media)
353
463
 
354
464
  # For simulator mode, return the response data in the HTTP response
355
465
  # instead of actually sending via WhatsApp API
356
- message_payload = @client.build_message_payload(rendered_message, context["request.msisdn"])
466
+ message_payload = @client.build_message_payload(response_data, context["request.msisdn"])
357
467
 
358
468
  simulator_response = {
359
469
  mode: "simulator",
@@ -361,7 +471,7 @@ module FlowChat
361
471
  would_send: message_payload,
362
472
  message_info: {
363
473
  to: context["request.msisdn"],
364
- contact_name: context["request.contact_name"],
474
+ contact_name: context["request.user_name"],
365
475
  timestamp: Time.now.iso8601
366
476
  }
367
477
  }
@@ -375,46 +485,23 @@ module FlowChat
375
485
  # Check if simulator mode is enabled for this processor
376
486
  return false unless context["enable_simulator"]
377
487
 
378
- # Then check if simulator mode is requested and valid
379
- @body.dig("simulator_mode") && valid_simulator_cookie?(context)
488
+ # Then check if simulator mode is requested and authorized
489
+ @body.dig("simulator_mode") &&
490
+ FlowChat::Security.valid_simulator_cookie?(@controller.request.cookies[FlowChat::Security::SIMULATOR_COOKIE_NAME])
380
491
  end
381
492
 
382
- def valid_simulator_cookie?(context)
383
- simulator_secret = FlowChat::Config.simulator_secret
384
- return false unless simulator_secret && !simulator_secret.empty?
385
-
386
- # Check for simulator cookie
387
- request = context.controller.request
388
- simulator_cookie = request.cookies["flowchat_simulator"]
389
- return false unless simulator_cookie
493
+ def parse_request_body(request)
494
+ return @body if @body
390
495
 
391
- # Verify the cookie is a valid HMAC signature
392
- # Cookie format: "timestamp:signature" where signature = HMAC(simulator_secret, "simulator:timestamp")
393
- begin
394
- timestamp_str, signature = simulator_cookie.split(":", 2)
395
- return false unless timestamp_str && signature
396
-
397
- # Check timestamp is recent (within 24 hours for reasonable session duration)
398
- timestamp = timestamp_str.to_i
399
- return false if timestamp <= 0
400
- return false if (Time.now.to_i - timestamp).abs > 86400 # 24 hours
401
-
402
- # Calculate expected signature
403
- message = "simulator:#{timestamp_str}"
404
- expected_signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), simulator_secret, message)
405
-
406
- # Use secure comparison
407
- secure_compare(signature, expected_signature)
408
- rescue => e
409
- Rails.logger.warn "Invalid simulator cookie format: #{e.message}"
410
- false
496
+ if request.body.nil?
497
+ FlowChat.logger.debug { "CloudApi: Request body is nil, returning empty hash" }
498
+ @body = {}
499
+ else
500
+ request.body.rewind if request.body.respond_to?(:rewind)
501
+ @body = JSON.parse(request.body.read)
411
502
  end
412
503
  end
413
504
 
414
- def parse_request_body(request)
415
- @body ||= JSON.parse(request.body.read)
416
- end
417
-
418
505
  def render_response(prompt, choices, media)
419
506
  FlowChat::Whatsapp::Renderer.new(prompt, choices: choices, media: media).render
420
507
  end