flow_chat 0.9.0 → 0.10.1

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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +29 -0
  3. data/.github/workflows/pages.yml +43 -0
  4. data/.gitignore +4 -1
  5. data/CHANGELOG.md +49 -0
  6. data/README.md +166 -295
  7. data/Rakefile +12 -1
  8. data/SECURITY.md +1 -1
  9. data/docs/architecture.md +52 -479
  10. data/docs/async-background-processing.md +31 -265
  11. data/docs/configuration.md +106 -613
  12. data/docs/factory-pattern.md +27 -324
  13. data/docs/gateway-context-variables.md +140 -143
  14. data/docs/gateway-development.md +86 -650
  15. data/docs/getting-started.md +40 -379
  16. data/docs/instrumentation.md +88 -279
  17. data/docs/platforms/instagram.md +278 -0
  18. data/docs/platforms/messenger.md +205 -0
  19. data/docs/platforms/telegram.md +47 -951
  20. data/docs/platforms/ussd.md +38 -653
  21. data/docs/platforms/whatsapp.md +73 -1321
  22. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md +732 -0
  23. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json +58 -0
  24. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md +4064 -0
  25. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json +226 -0
  26. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md +972 -0
  27. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json +88 -0
  28. data/docs/superpowers/specs/2026-07-09-inbound-media-support-design.md +195 -0
  29. data/docs/superpowers/specs/2026-08-10-messenger-instagram-design.md +391 -0
  30. data/docs/testing.md +26 -297
  31. data/examples/http_controller.rb +12 -10
  32. data/examples/intercom_configuration_example.rb +19 -24
  33. data/examples/intercom_controller.rb +8 -20
  34. data/examples/multi_tenant_whatsapp_controller.rb +61 -166
  35. data/examples/simulator_controller.rb +0 -1
  36. data/examples/ussd_controller.rb +86 -158
  37. data/examples/whatsapp_controller.rb +16 -15
  38. data/examples/whatsapp_media_examples.rb +27 -79
  39. data/flow_chat.gemspec +22 -2
  40. data/gemfiles/rails_main.gemfile +18 -0
  41. data/lib/flow_chat/app.rb +129 -11
  42. data/lib/flow_chat/async_job.rb +10 -0
  43. data/lib/flow_chat/choice_titles.rb +95 -0
  44. data/lib/flow_chat/config.rb +124 -1
  45. data/lib/flow_chat/delivery_error.rb +9 -0
  46. data/lib/flow_chat/gateway_async_support.rb +19 -1
  47. data/lib/flow_chat/http/configuration_error.rb +9 -0
  48. data/lib/flow_chat/http/gateway/simple.rb +24 -4
  49. data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
  50. data/lib/flow_chat/input.rb +86 -0
  51. data/lib/flow_chat/instagram/client.rb +32 -0
  52. data/lib/flow_chat/instagram/configuration.rb +147 -0
  53. data/lib/flow_chat/instagram/configuration_error.rb +7 -0
  54. data/lib/flow_chat/instagram/gateway/send_api.rb +63 -0
  55. data/lib/flow_chat/instagram/middleware/choice_mapper.rb +22 -0
  56. data/lib/flow_chat/instagram/renderer.rb +23 -0
  57. data/lib/flow_chat/instrumentation/metrics_collector.rb +6 -1
  58. data/lib/flow_chat/instrumentation.rb +160 -1
  59. data/lib/flow_chat/intercom/client.rb +34 -28
  60. data/lib/flow_chat/intercom/configuration.rb +2 -49
  61. data/lib/flow_chat/intercom/configuration_error.rb +9 -0
  62. data/lib/flow_chat/intercom/gateway/intercom_api.rb +81 -57
  63. data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
  64. data/lib/flow_chat/intercom/renderer.rb +57 -5
  65. data/lib/flow_chat/media.rb +121 -0
  66. data/lib/flow_chat/messenger/client.rb +264 -0
  67. data/lib/flow_chat/messenger/configuration.rb +103 -0
  68. data/lib/flow_chat/messenger/configuration_error.rb +9 -0
  69. data/lib/flow_chat/messenger/gateway/send_api.rb +42 -0
  70. data/lib/flow_chat/messenger/middleware/choice_mapper.rb +185 -0
  71. data/lib/flow_chat/messenger/renderer.rb +150 -0
  72. data/lib/flow_chat/meta/challenge.rb +24 -0
  73. data/lib/flow_chat/meta/choice_ladder.rb +37 -0
  74. data/lib/flow_chat/meta/configuration_error.rb +7 -0
  75. data/lib/flow_chat/meta/gateway_identity.rb +38 -0
  76. data/lib/flow_chat/meta/messaging_gateway.rb +471 -0
  77. data/lib/flow_chat/meta/signature.rb +30 -0
  78. data/lib/flow_chat/meta/signature_validation.rb +66 -0
  79. data/lib/flow_chat/meta/webhook_verification.rb +43 -0
  80. data/lib/flow_chat/named_configuration.rb +65 -0
  81. data/lib/flow_chat/prompt.rb +13 -16
  82. data/lib/flow_chat/renderers/markdown_support.rb +109 -0
  83. data/lib/flow_chat/security.rb +76 -0
  84. data/lib/flow_chat/session/middleware.rb +11 -2
  85. data/lib/flow_chat/simulator/controller.rb +31 -15
  86. data/lib/flow_chat/simulator/views/simulator.html.erb +184 -20
  87. data/lib/flow_chat/telegram/client.rb +47 -4
  88. data/lib/flow_chat/telegram/configuration.rb +2 -42
  89. data/lib/flow_chat/telegram/configuration_error.rb +9 -0
  90. data/lib/flow_chat/telegram/gateway/bot_api.rb +46 -28
  91. data/lib/flow_chat/telegram/middleware/choice_mapper.rb +77 -16
  92. data/lib/flow_chat/telegram/renderer.rb +10 -2
  93. data/lib/flow_chat/text_truncator.rb +75 -0
  94. data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
  95. data/lib/flow_chat/version.rb +1 -1
  96. data/lib/flow_chat/whatsapp/client.rb +14 -7
  97. data/lib/flow_chat/whatsapp/configuration.rb +12 -51
  98. data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
  99. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +259 -203
  100. data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +144 -57
  101. data/lib/flow_chat/whatsapp/renderer.rb +121 -60
  102. data/lib/flow_chat.rb +0 -10
  103. data/lib/tasks/release.rake +11 -1
  104. data/site/.nojekyll +0 -0
  105. data/site/.og-card.html +89 -0
  106. data/site/favicon.svg +6 -0
  107. data/site/index.html +209 -0
  108. data/site/og.png +0 -0
  109. metadata +60 -11
  110. 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,237 @@ 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
+ # Announced by whichever pass is first to see the delivery, and
151
+ # only once. With async enabled the gem publishes here, enqueues,
152
+ # and the job re-enters this method on the same body, so
153
+ # publishing in both double-counted every receipt. The job the
154
+ # gem enqueues carries word that they are already out and skips
155
+ # them, which leaves the request publishing: it already holds
156
+ # the delivery, so a receipt is announced when it arrives rather
157
+ # than when the queue reaches it, and survives a job that is
158
+ # never picked up. A background pass with no foreground pass
159
+ # behind it, as an application enqueuing its own jobs has,
160
+ # publishes them itself.
161
+ handle_statuses(value) if value["statuses"].present? && !side_events_already_published?
162
+
163
+ next if value["messages"].blank?
164
+
165
+ if flow_ran
166
+ FlowChat.logger.warn { "CloudApi: A second messages change arrived in the same delivery and was not processed" }
167
+ next
168
+ end
169
+ flow_ran = true
170
+
171
+ case handle_messages(context, value)
172
+ when :rejected then return @controller.head :forbidden
173
+ when :enqueued then return @controller.head :ok
174
+ when :rendered then return nil # simulator already wrote the response
175
+ end
176
+ when "statuses"
177
+ # Only reachable for a payload built without a field name, which
178
+ # our own fixtures do and Meta does not.
179
+ handle_statuses(value) unless side_events_already_published?
180
+ else
181
+ # Anything that is not a message or its delivery. Coexistence
182
+ # echoes, contact syncs, imported history, account bans, template
183
+ # approvals: all of it is the application's domain, so it is
184
+ # published rather than interpreted here.
185
+ handle_unmodelled_field(change["field"], value, entry["id"]) unless side_events_already_published?
216
186
  end
217
187
  end
218
188
  end
219
189
 
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
190
  @controller.head :ok
228
191
  end
229
192
 
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
193
+ # Meta names the change in `field`. Older payloads, and the ones our own
194
+ # tests build, leave it out, so fall back to what the value carries.
195
+ def webhook_field(change, value)
196
+ return change["field"] if change["field"].present?
197
+ return "messages" if value["messages"].is_a?(Array) && value["messages"].any?
198
+ return "statuses" if value["statuses"].is_a?(Array) && value["statuses"].any?
199
+
200
+ nil
201
+ end
237
202
 
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
203
+ # Returns what happened, so the caller can decide whether it still owns the
204
+ # response: :rejected, :enqueued, :rendered, :processed or :skipped.
205
+ def handle_messages(context, value)
206
+ message = value["messages"]&.first
207
+ return :skipped unless message
208
+
209
+ contact = value["contacts"]&.first
210
+
211
+ phone_number = FlowChat::PhoneNumberUtil.to_e164(message["from"])
212
+ message_id = message["id"]
213
+ contact_name = contact&.dig("profile", "name")
214
+ business_phone_number = value.dig("metadata", "display_phone_number")
215
+ business_phone_number_id = value.dig("metadata", "phone_number_id")
216
+
217
+ # Validate that webhook is for our configured phone number. Skipped
218
+ # in simulator mode rather than requiring the simulator to send an
219
+ # id that matches a real configuration: the simulator's whole
220
+ # point is running a turn with no live credentials at hand. Safe
221
+ # to skip, not a hole for real traffic, because
222
+ # context["simulator_mode"] is only ever true once simulate? has
223
+ # already checked the signed simulator cookie, above in
224
+ # handle_webhook.
225
+ if !context["simulator_mode"] && business_phone_number_id != @config.phone_number_id
226
+ FlowChat.logger.warn { "CloudApi: Webhook received for phone_number_id '#{business_phone_number_id}' but configured for '#{@config.phone_number_id}' - rejecting" }
227
+ return :rejected
244
228
  end
245
229
 
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
230
+ context["request.id"] = phone_number
231
+ context["request.user_id"] = phone_number
232
+ context["request.user_name"] = contact_name if contact_name
233
+ context["request.msisdn"] = phone_number
234
+ context["request.gateway"] = :whatsapp_cloud_api
235
+ context["request.platform"] = :whatsapp
236
+ context["request.message_id"] = message_id
237
+ context["request.timestamp"] = Time.current.iso8601
238
+ context["request.body"] = @body
239
+
240
+ context["whatsapp.business.phone_number"] = FlowChat::PhoneNumberUtil.to_e164(business_phone_number)
241
+ context["whatsapp.business.phone_number_id"] = business_phone_number_id
242
+ context["whatsapp.client"] = @client
243
+
244
+ # Extract message content based on type
245
+ extract_message_content!(message, context)
246
+
247
+ if inbound_message?(context)
248
+ # Use instrumentation for message received
249
+ instrument(Events::MESSAGE_RECEIVED, {
250
+ from: phone_number,
251
+ message: context.input,
252
+ message_type: message["type"],
253
+ message_id: message_id
254
+ })
250
255
  end
251
256
 
252
- # Extract signature from header (format: "sha256=<signature>")
253
- expected_signature = signature_header.sub("sha256=", "")
257
+ FlowChat.logger.debug { "CloudApi: Message content extracted - Type: #{message["type"]}, Input: '#{context.input}'" }
254
258
 
255
- # Get raw request body
256
- request.body.rewind
257
- body = request.body.read
258
- request.body.rewind
259
+ # Determine routing: async enqueue, background execute, or inline
260
+ if should_enqueue_async?
261
+ # Webhook with async enabled → enqueue job and return immediately
262
+ enqueue_async_job
263
+ return :enqueued
264
+ end
259
265
 
260
- # Calculate HMAC signature
261
- calculated_signature = OpenSSL::HMAC.hexdigest(
262
- OpenSSL::Digest.new("sha256"),
263
- @config.app_secret,
264
- body
265
- )
266
+ # Background OR inline → process message
267
+ case determine_message_handler(context)
268
+ when :inline
269
+ handle_message_inline(context, @controller)
270
+ :processed
271
+ when :simulator
272
+ handle_message_simulator(context, @controller)
273
+ :rendered
274
+ end
275
+ end
266
276
 
267
- # Compare signatures using secure comparison to prevent timing attacks
268
- signature_valid = secure_compare(expected_signature, calculated_signature)
277
+ def handle_statuses(value)
278
+ statuses = value["statuses"]
279
+ return if statuses.blank?
269
280
 
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
281
+ FlowChat.logger.info { "CloudApi: Received #{statuses.size} status update(s)" }
282
+ FlowChat.logger.debug { "CloudApi: Status updates: #{statuses.inspect}" }
275
283
 
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
284
+ statuses.each do |status|
285
+ instrument(Events::MESSAGE_STATUS, {
286
+ platform: :whatsapp,
287
+ gateway: :whatsapp_cloud_api,
288
+ business_phone_number_id: value.dig("metadata", "phone_number_id"),
289
+ message_id: status["id"],
290
+ recipient: status["recipient_id"],
291
+ status: status["status"],
292
+ timestamp: status["timestamp"],
293
+ errors: status["errors"],
294
+ # Meta reports more about a delivery than a status and a time: what
295
+ # it billed the conversation as, and its own view of the window.
296
+ # None of it is this gem's business to interpret, and all of it is
297
+ # gone if the named keys are the only way through.
298
+ value: status
299
+ })
300
+ end
282
301
  end
283
302
 
284
- # Secure string comparison to prevent timing attacks
285
- def secure_compare(a, b)
286
- return false unless a.bytesize == b.bytesize
303
+ # Everything that is not a message or its delivery. Verified, named, and
304
+ # handed on whole for the application to make sense of.
305
+ #
306
+ # The account id comes from the entry rather than the value because a change
307
+ # about the account itself names no phone number: a ban, a review outcome, a
308
+ # template approval. Without it those arrive identifying nothing, and an
309
+ # application holding several businesses cannot tell whose they are.
310
+ def handle_unmodelled_field(field, value, business_account_id)
311
+ FlowChat.logger.info {
312
+ "CloudApi: Publishing webhook field '#{field}' (value keys: #{value.keys.join(", ")})"
313
+ }
314
+
315
+ payload = {
316
+ platform: :whatsapp,
317
+ gateway: :whatsapp_cloud_api,
318
+ field: field,
319
+ business_account_id: business_account_id,
320
+ business_phone_number: value.dig("metadata", "display_phone_number"),
321
+ business_phone_number_id: value.dig("metadata", "phone_number_id"),
322
+ value: value
323
+ }
324
+
325
+ origin = echo_origin(field, value)
326
+ payload[:echo_origin] = origin if origin
327
+
328
+ instrument(Events::WEBHOOK_RECEIVED, payload)
329
+ end
287
330
 
288
- l = a.unpack("C*")
289
- res = 0
290
- b.each_byte { |byte| res |= byte ^ l.shift }
291
- res == 0
331
+ # An echo reports a message sent on the thread by someone other than the
332
+ # user we are talking to. Which someone matters: a human replying from the
333
+ # business inbox usually means the application should stand the flow
334
+ # down, while our own send coming back means nothing at all. Only the
335
+ # app_id separates them, and only this gateway knows our own, so it is
336
+ # derived here rather than left for every subscriber to work out.
337
+ #
338
+ # Meta names the coexistence echo field "smb_message_echoes" and nests the
339
+ # echoes themselves under "message_echoes"; both are matched precisely
340
+ # rather than by a loose "echo" substring or "first array in the value",
341
+ # since a field this gateway does not otherwise interpret could carry
342
+ # other arrays under other names.
343
+ def echo_origin(field, value)
344
+ return nil unless field == "smb_message_echoes"
345
+
346
+ app_id = value["message_echoes"]&.first&.dig("app_id")
347
+
348
+ return :human_agent if app_id.blank?
349
+ return :self if app_id.to_s == @config.app_id.to_s
350
+
351
+ :other_app
292
352
  end
293
353
 
294
354
  def extract_message_content!(message, context)
@@ -319,7 +379,7 @@ module FlowChat
319
379
  address: message.dig("location", "address")
320
380
  }
321
381
  context["request.location"] = location
322
- context.input = FlowChat::Input::LOCATION
382
+ context.input = ""
323
383
  FlowChat.logger.debug { "CloudApi: Location received - Lat: #{location[:latitude]}, Lng: #{location[:longitude]}" }
324
384
  when "image", "document", "audio", "video", "sticker"
325
385
  media_data = message[message["type"]]
@@ -332,7 +392,8 @@ module FlowChat
332
392
  sha256: media_data["sha256"],
333
393
  animated: media_data["animated"]
334
394
  }
335
- context.input = FlowChat::Input::MEDIA
395
+ # The caption (if any) is the turn's text; a text-less media message has blank input.
396
+ context.input = media_data["caption"].presence || ""
336
397
  FlowChat.logger.debug { "CloudApi: Media received - Type: #{message["type"]}, ID: #{media_data["id"]}" }
337
398
  when "contacts"
338
399
  # WhatsApp sends contacts as an array, take the first one
@@ -346,7 +407,7 @@ module FlowChat
346
407
  phones: phones.map { |p| p["phone"] },
347
408
  phone_number: phones.first&.dig("phone")
348
409
  }
349
- context.input = FlowChat::Input::CONTACT
410
+ context.input = ""
350
411
  FlowChat.logger.debug { "CloudApi: Contact received - Name: #{context["request.contact"][:name]}" }
351
412
  end
352
413
  end
@@ -356,9 +417,24 @@ module FlowChat
356
417
  response = @app.call(context)
357
418
  if response
358
419
  type, prompt, choices, media = response
359
- result = @client.send_message(context["request.msisdn"], prompt, choices: choices, media: media)
420
+ result = report_delivery_failure(
421
+ context,
422
+ to: context["request.msisdn"],
423
+ session_id: context["request.id"],
424
+ message: prompt,
425
+ message_type: (type == :prompt) ? "prompt" : "terminal",
426
+ gateway: :whatsapp_cloud_api,
427
+ platform: :whatsapp
428
+ ) do
429
+ @client.send_message(context["request.msisdn"], prompt, choices: choices, media: media)
430
+ end
360
431
  context["whatsapp.message_result"] = result
361
432
 
433
+ # report_delivery_failure already reported this; a nil result
434
+ # means the platform did not accept the message, and instrumenting
435
+ # MESSAGE_SENT anyway counted a delivery that never happened.
436
+ return unless result
437
+
362
438
  # Instrument message sent
363
439
  instrument(Events::MESSAGE_SENT, {
364
440
  to: context["request.msisdn"],
@@ -368,11 +444,21 @@ module FlowChat
368
444
  gateway: :whatsapp_cloud_api,
369
445
  platform: :whatsapp,
370
446
  content_length: prompt.to_s.length,
447
+ # What Meta called it, so this and message.status can be joined.
448
+ platform_message_id: platform_message_id_from(result),
449
+ duration_ms: context[FlowChat::Instrumentation::DELIVERY_DURATION_KEY],
371
450
  timestamp: context["request.timestamp"]
372
451
  })
373
452
  end
374
453
  end
375
454
 
455
+ # Meta answers a send with the ids it assigned.
456
+ def platform_message_id_from(result)
457
+ return nil unless result.is_a?(Hash)
458
+
459
+ result.dig("messages", 0, "id")
460
+ end
461
+
376
462
  def handle_message_simulator(context, controller)
377
463
  response = @app.call(context)
378
464
 
@@ -404,39 +490,9 @@ module FlowChat
404
490
  # Check if simulator mode is enabled for this processor
405
491
  return false unless context["enable_simulator"]
406
492
 
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
493
+ # Then check if simulator mode is requested and authorized
494
+ @body.dig("simulator_mode") &&
495
+ FlowChat::Security.valid_simulator_cookie?(@controller.request.cookies[FlowChat::Security::SIMULATOR_COOKIE_NAME])
440
496
  end
441
497
 
442
498
  def parse_request_body(request)