flow_chat 0.9.0 → 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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +43 -0
  3. data/CHANGELOG.md +26 -0
  4. data/README.md +166 -295
  5. data/Rakefile +12 -1
  6. data/SECURITY.md +1 -1
  7. data/docs/architecture.md +52 -479
  8. data/docs/async-background-processing.md +31 -265
  9. data/docs/configuration.md +106 -613
  10. data/docs/factory-pattern.md +27 -324
  11. data/docs/gateway-context-variables.md +140 -143
  12. data/docs/gateway-development.md +86 -650
  13. data/docs/getting-started.md +40 -379
  14. data/docs/instrumentation.md +88 -279
  15. data/docs/platforms/instagram.md +278 -0
  16. data/docs/platforms/messenger.md +205 -0
  17. data/docs/platforms/telegram.md +47 -951
  18. data/docs/platforms/ussd.md +38 -653
  19. data/docs/platforms/whatsapp.md +73 -1321
  20. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md +732 -0
  21. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json +58 -0
  22. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md +4064 -0
  23. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json +226 -0
  24. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md +972 -0
  25. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json +88 -0
  26. data/docs/superpowers/specs/2026-07-09-inbound-media-support-design.md +195 -0
  27. data/docs/superpowers/specs/2026-08-10-messenger-instagram-design.md +391 -0
  28. data/docs/testing.md +26 -297
  29. data/examples/http_controller.rb +12 -10
  30. data/examples/intercom_configuration_example.rb +19 -24
  31. data/examples/intercom_controller.rb +8 -20
  32. data/examples/multi_tenant_whatsapp_controller.rb +61 -166
  33. data/examples/simulator_controller.rb +0 -1
  34. data/examples/ussd_controller.rb +86 -158
  35. data/examples/whatsapp_controller.rb +16 -15
  36. data/examples/whatsapp_media_examples.rb +27 -79
  37. data/lib/flow_chat/app.rb +129 -11
  38. data/lib/flow_chat/choice_titles.rb +95 -0
  39. data/lib/flow_chat/config.rb +124 -1
  40. data/lib/flow_chat/delivery_error.rb +9 -0
  41. data/lib/flow_chat/http/configuration_error.rb +9 -0
  42. data/lib/flow_chat/http/gateway/simple.rb +24 -4
  43. data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
  44. data/lib/flow_chat/input.rb +86 -0
  45. data/lib/flow_chat/instagram/client.rb +32 -0
  46. data/lib/flow_chat/instagram/configuration.rb +147 -0
  47. data/lib/flow_chat/instagram/configuration_error.rb +7 -0
  48. data/lib/flow_chat/instagram/gateway/send_api.rb +63 -0
  49. data/lib/flow_chat/instagram/middleware/choice_mapper.rb +22 -0
  50. data/lib/flow_chat/instagram/renderer.rb +23 -0
  51. data/lib/flow_chat/instrumentation/metrics_collector.rb +6 -1
  52. data/lib/flow_chat/instrumentation.rb +160 -1
  53. data/lib/flow_chat/intercom/client.rb +34 -28
  54. data/lib/flow_chat/intercom/configuration.rb +2 -49
  55. data/lib/flow_chat/intercom/configuration_error.rb +9 -0
  56. data/lib/flow_chat/intercom/gateway/intercom_api.rb +81 -57
  57. data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
  58. data/lib/flow_chat/intercom/renderer.rb +57 -5
  59. data/lib/flow_chat/media.rb +121 -0
  60. data/lib/flow_chat/messenger/client.rb +264 -0
  61. data/lib/flow_chat/messenger/configuration.rb +103 -0
  62. data/lib/flow_chat/messenger/configuration_error.rb +9 -0
  63. data/lib/flow_chat/messenger/gateway/send_api.rb +42 -0
  64. data/lib/flow_chat/messenger/middleware/choice_mapper.rb +185 -0
  65. data/lib/flow_chat/messenger/renderer.rb +150 -0
  66. data/lib/flow_chat/meta/challenge.rb +24 -0
  67. data/lib/flow_chat/meta/choice_ladder.rb +37 -0
  68. data/lib/flow_chat/meta/configuration_error.rb +7 -0
  69. data/lib/flow_chat/meta/gateway_identity.rb +38 -0
  70. data/lib/flow_chat/meta/messaging_gateway.rb +468 -0
  71. data/lib/flow_chat/meta/signature.rb +30 -0
  72. data/lib/flow_chat/meta/signature_validation.rb +66 -0
  73. data/lib/flow_chat/meta/webhook_verification.rb +43 -0
  74. data/lib/flow_chat/named_configuration.rb +65 -0
  75. data/lib/flow_chat/prompt.rb +13 -16
  76. data/lib/flow_chat/renderers/markdown_support.rb +109 -0
  77. data/lib/flow_chat/security.rb +76 -0
  78. data/lib/flow_chat/session/middleware.rb +11 -2
  79. data/lib/flow_chat/simulator/controller.rb +31 -15
  80. data/lib/flow_chat/simulator/views/simulator.html.erb +184 -20
  81. data/lib/flow_chat/telegram/client.rb +47 -4
  82. data/lib/flow_chat/telegram/configuration.rb +2 -42
  83. data/lib/flow_chat/telegram/configuration_error.rb +9 -0
  84. data/lib/flow_chat/telegram/gateway/bot_api.rb +46 -28
  85. data/lib/flow_chat/telegram/middleware/choice_mapper.rb +77 -16
  86. data/lib/flow_chat/telegram/renderer.rb +10 -2
  87. data/lib/flow_chat/text_truncator.rb +75 -0
  88. data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
  89. data/lib/flow_chat/version.rb +1 -1
  90. data/lib/flow_chat/whatsapp/client.rb +14 -7
  91. data/lib/flow_chat/whatsapp/configuration.rb +12 -51
  92. data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
  93. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +254 -203
  94. data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +144 -57
  95. data/lib/flow_chat/whatsapp/renderer.rb +121 -60
  96. data/lib/flow_chat.rb +0 -10
  97. data/site/.nojekyll +0 -0
  98. data/site/.og-card.html +89 -0
  99. data/site/favicon.svg +6 -0
  100. data/site/index.html +209 -0
  101. data/site/og.png +0 -0
  102. metadata +51 -3
  103. data/lib/flow_chat/whatsapp/id_generator.rb +0 -124
@@ -1,16 +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
13
9
  include FlowChat::GatewayAsyncSupport
10
+ include FlowChat::Meta::SignatureValidation
11
+ include FlowChat::Meta::WebhookVerification
14
12
 
15
13
  attr_reader :context
16
14
 
@@ -63,6 +61,23 @@ module FlowChat
63
61
  FlowChat.logger.debug { "CloudApi: Added Whatsapp::Middleware::ChoiceMapper" }
64
62
  end
65
63
 
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
72
+
73
+ def platform_label
74
+ "WhatsApp"
75
+ end
76
+
77
+ def configuration_error_class
78
+ FlowChat::Whatsapp::ConfigurationError
79
+ end
80
+
66
81
  private
67
82
 
68
83
  def determine_message_handler(context)
@@ -76,34 +91,6 @@ module FlowChat
76
91
  end
77
92
  end
78
93
 
79
- def handle_verification(context)
80
- params = @controller.request.params
81
-
82
- verify_token = @config.verify_token
83
- provided_token = params["hub.verify_token"]
84
- challenge = params["hub.challenge"]
85
-
86
- FlowChat.logger.debug { "CloudApi: Webhook verification - provided token matches: #{provided_token == verify_token}" }
87
-
88
- if provided_token == verify_token
89
- # Use instrumentation for webhook verification success
90
- instrument(Events::WEBHOOK_VERIFIED, {
91
- challenge: challenge,
92
- platform: :whatsapp
93
- })
94
-
95
- @controller.render plain: challenge
96
- else
97
- # Use instrumentation for webhook verification failure
98
- instrument(Events::WEBHOOK_FAILED, {
99
- reason: "Invalid verify token",
100
- platform: :whatsapp
101
- })
102
-
103
- @controller.head :forbidden
104
- end
105
- end
106
-
107
94
  def handle_webhook(context)
108
95
  # Parse body
109
96
  begin
@@ -131,164 +118,232 @@ module FlowChat
131
118
 
132
119
  FlowChat.logger.debug { "CloudApi: Webhook signature validation passed" }
133
120
 
134
- # Extract message data from WhatsApp webhook
135
- entry = @body.dig("entry", 0)
136
- 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?
137
126
  FlowChat.logger.debug { "CloudApi: No entry found in webhook body - returning OK" }
138
127
  return @controller.head :ok
139
128
  end
140
129
 
141
- changes = entry.dig("changes", 0)
142
- unless changes
143
- FlowChat.logger.debug { "CloudApi: No changes found in webhook entry - returning OK" }
144
- return @controller.head :ok
145
- end
146
-
147
- value = changes["value"]
148
- unless value
149
- FlowChat.logger.debug { "CloudApi: No value found in webhook changes - returning OK" }
150
- return @controller.head :ok
151
- end
152
-
153
- # Handle incoming messages
154
- if value["messages"]&.any?
155
- message = value["messages"].first
156
- contact = value["contacts"]&.first
157
-
158
- phone_number = FlowChat::PhoneNumberUtil.to_e164(message["from"])
159
- message_id = message["id"]
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
169
-
170
- context["request.id"] = phone_number
171
- context["request.user_id"] = phone_number
172
- context["request.user_name"] = contact_name if contact_name
173
- context["request.msisdn"] = phone_number
174
- context["request.gateway"] = :whatsapp_cloud_api
175
- context["request.platform"] = :whatsapp
176
- context["request.message_id"] = message_id
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
183
-
184
- # Extract message content based on type
185
- extract_message_content!(message, context)
186
-
187
- if context.input.present?
188
- # Use instrumentation for message received
189
- instrument(Events::MESSAGE_RECEIVED, {
190
- from: phone_number,
191
- message: context.input,
192
- message_type: message["type"],
193
- message_id: message_id
194
- })
195
- end
196
-
197
- FlowChat.logger.debug { "CloudApi: Message content extracted - Type: #{message["type"]}, Input: '#{context.input}'" }
198
-
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)
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?
216
181
  end
217
182
  end
218
183
  end
219
184
 
220
- # Handle message status updates
221
- if value["statuses"]&.any?
222
- statuses = value["statuses"]
223
- FlowChat.logger.info { "CloudApi: Received #{statuses.size} status update(s)" }
224
- FlowChat.logger.debug { "CloudApi: Status updates: #{statuses.inspect}" }
225
- end
226
-
227
185
  @controller.head :ok
228
186
  end
229
187
 
230
- # Validate webhook signature to ensure request comes from WhatsApp
231
- def valid_webhook_signature?(request)
232
- # Check if signature validation is explicitly disabled
233
- if @config.skip_signature_validation
234
- FlowChat.logger.debug { "CloudApi: Webhook signature validation is disabled" }
235
- return true
236
- end
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?
194
+
195
+ nil
196
+ end
237
197
 
238
- # Require app_secret for signature validation
239
- unless @config.app_secret && !@config.app_secret.empty?
240
- error_msg = "WhatsApp app_secret is required for webhook signature validation. " \
241
- "Either configure app_secret or set skip_signature_validation=true to explicitly disable validation."
242
- FlowChat.logger.error { "CloudApi: #{error_msg}" }
243
- raise FlowChat::Whatsapp::ConfigurationError, error_msg
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
244
223
  end
245
224
 
246
- signature_header = request.headers["X-Hub-Signature-256"]
247
- unless signature_header
248
- FlowChat.logger.warn { "CloudApi: No X-Hub-Signature-256 header found in request" }
249
- return false
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
+ })
250
250
  end
251
251
 
252
- # Extract signature from header (format: "sha256=<signature>")
253
- expected_signature = signature_header.sub("sha256=", "")
252
+ FlowChat.logger.debug { "CloudApi: Message content extracted - Type: #{message["type"]}, Input: '#{context.input}'" }
254
253
 
255
- # Get raw request body
256
- request.body.rewind
257
- body = request.body.read
258
- request.body.rewind
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
259
+ end
259
260
 
260
- # Calculate HMAC signature
261
- calculated_signature = OpenSSL::HMAC.hexdigest(
262
- OpenSSL::Digest.new("sha256"),
263
- @config.app_secret,
264
- body
265
- )
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
269
+ end
270
+ end
266
271
 
267
- # Compare signatures using secure comparison to prevent timing attacks
268
- signature_valid = secure_compare(expected_signature, calculated_signature)
272
+ def handle_statuses(value)
273
+ statuses = value["statuses"]
274
+ return if statuses.blank?
269
275
 
270
- if signature_valid
271
- FlowChat.logger.debug { "CloudApi: Webhook signature validation successful" }
272
- else
273
- FlowChat.logger.warn { "CloudApi: Webhook signature validation failed - signatures do not match" }
274
- end
276
+ FlowChat.logger.info { "CloudApi: Received #{statuses.size} status update(s)" }
277
+ FlowChat.logger.debug { "CloudApi: Status updates: #{statuses.inspect}" }
275
278
 
276
- signature_valid
277
- rescue FlowChat::Whatsapp::ConfigurationError
278
- raise
279
- rescue => e
280
- FlowChat.logger.error { "CloudApi: Error validating webhook signature: #{e.class.name}: #{e.message}" }
281
- false
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
+ })
295
+ end
282
296
  end
283
297
 
284
- # Secure string comparison to prevent timing attacks
285
- def secure_compare(a, b)
286
- 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
287
325
 
288
- l = a.unpack("C*")
289
- res = 0
290
- b.each_byte { |byte| res |= byte ^ l.shift }
291
- 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
292
347
  end
293
348
 
294
349
  def extract_message_content!(message, context)
@@ -319,7 +374,7 @@ module FlowChat
319
374
  address: message.dig("location", "address")
320
375
  }
321
376
  context["request.location"] = location
322
- context.input = FlowChat::Input::LOCATION
377
+ context.input = ""
323
378
  FlowChat.logger.debug { "CloudApi: Location received - Lat: #{location[:latitude]}, Lng: #{location[:longitude]}" }
324
379
  when "image", "document", "audio", "video", "sticker"
325
380
  media_data = message[message["type"]]
@@ -332,7 +387,8 @@ module FlowChat
332
387
  sha256: media_data["sha256"],
333
388
  animated: media_data["animated"]
334
389
  }
335
- context.input = FlowChat::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 || ""
336
392
  FlowChat.logger.debug { "CloudApi: Media received - Type: #{message["type"]}, ID: #{media_data["id"]}" }
337
393
  when "contacts"
338
394
  # WhatsApp sends contacts as an array, take the first one
@@ -346,7 +402,7 @@ module FlowChat
346
402
  phones: phones.map { |p| p["phone"] },
347
403
  phone_number: phones.first&.dig("phone")
348
404
  }
349
- context.input = FlowChat::Input::CONTACT
405
+ context.input = ""
350
406
  FlowChat.logger.debug { "CloudApi: Contact received - Name: #{context["request.contact"][:name]}" }
351
407
  end
352
408
  end
@@ -356,9 +412,24 @@ module FlowChat
356
412
  response = @app.call(context)
357
413
  if response
358
414
  type, prompt, choices, media = response
359
- result = @client.send_message(context["request.msisdn"], prompt, choices: choices, media: media)
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
360
426
  context["whatsapp.message_result"] = result
361
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
+
362
433
  # Instrument message sent
363
434
  instrument(Events::MESSAGE_SENT, {
364
435
  to: context["request.msisdn"],
@@ -368,11 +439,21 @@ module FlowChat
368
439
  gateway: :whatsapp_cloud_api,
369
440
  platform: :whatsapp,
370
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],
371
445
  timestamp: context["request.timestamp"]
372
446
  })
373
447
  end
374
448
  end
375
449
 
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)
453
+
454
+ result.dig("messages", 0, "id")
455
+ end
456
+
376
457
  def handle_message_simulator(context, controller)
377
458
  response = @app.call(context)
378
459
 
@@ -404,39 +485,9 @@ module FlowChat
404
485
  # Check if simulator mode is enabled for this processor
405
486
  return false unless context["enable_simulator"]
406
487
 
407
- # Then check if simulator mode is requested and valid
408
- @body.dig("simulator_mode") && valid_simulator_cookie?(context)
409
- end
410
-
411
- def valid_simulator_cookie?(context)
412
- simulator_secret = FlowChat::Config.simulator_secret
413
- return false unless simulator_secret && !simulator_secret.empty?
414
-
415
- # Check for simulator cookie
416
- simulator_cookie = @controller.request.cookies["flowchat_simulator"]
417
- return false unless simulator_cookie
418
-
419
- # Verify the cookie is a valid HMAC signature
420
- # Cookie format: "timestamp:signature" where signature = HMAC(simulator_secret, "simulator:timestamp")
421
- begin
422
- timestamp_str, signature = simulator_cookie.split(":", 2)
423
- return false unless timestamp_str && signature
424
-
425
- # Check timestamp is recent (within 24 hours for reasonable session duration)
426
- timestamp = timestamp_str.to_i
427
- return false if timestamp <= 0
428
- return false if (Time.now.to_i - timestamp).abs > 86400 # 24 hours
429
-
430
- # Calculate expected signature
431
- message = "simulator:#{timestamp_str}"
432
- expected_signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), simulator_secret, message)
433
-
434
- # Use secure comparison
435
- secure_compare(signature, expected_signature)
436
- rescue => e
437
- Rails.logger.warn "Invalid simulator cookie format: #{e.message}"
438
- false
439
- end
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])
440
491
  end
441
492
 
442
493
  def parse_request_body(request)