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
@@ -0,0 +1,468 @@
1
+ require "json"
2
+
3
+ module FlowChat
4
+ module Meta
5
+ # The Messenger Platform envelope, shared by Facebook Messenger and
6
+ # Instagram DMs. Both deliver entry[].messaging[] and both send through the
7
+ # same Send API, so the envelope is implemented once and each platform
8
+ # supplies only what actually differs.
9
+ class MessagingGateway
10
+ include FlowChat::Instrumentation
11
+ include FlowChat::GatewayAsyncSupport
12
+ include FlowChat::Meta::SignatureValidation
13
+ include FlowChat::Meta::WebhookVerification
14
+
15
+ attr_reader :context, :client
16
+
17
+ def initialize(app, config = nil)
18
+ @app = app
19
+ @config = config || configuration_class.from_credentials
20
+ @client = client_class.new(@config)
21
+
22
+ FlowChat.logger.info { "#{log_tag}: Initialized #{platform} gateway for account #{@config.account_id}" }
23
+ end
24
+
25
+ def call(context)
26
+ @context = context
27
+ @controller = context.controller
28
+ request = @controller.request
29
+
30
+ unless in_background?
31
+ if request.get? && request.params["hub.mode"] == "subscribe"
32
+ return handle_verification(context)
33
+ end
34
+ end
35
+
36
+ return handle_webhook(context) if request.post?
37
+
38
+ FlowChat.logger.warn { "#{log_tag}: Invalid request method or parameters - returning bad request" }
39
+ @controller.head :bad_request
40
+ end
41
+
42
+ def self.configure_middleware_stack(builder, custom_middleware)
43
+ builder.use custom_middleware
44
+ builder.use choice_mapper_class
45
+ end
46
+
47
+ # --- Hooks each platform overrides ---
48
+
49
+ def gateway_name
50
+ raise NotImplementedError
51
+ end
52
+
53
+ def configuration_class
54
+ raise NotImplementedError
55
+ end
56
+
57
+ def client_class
58
+ raise NotImplementedError
59
+ end
60
+
61
+ def renderer_class
62
+ raise NotImplementedError
63
+ end
64
+
65
+ # Meta names the subscription this delivery came from. Messenger uses
66
+ # "page". Instagram's value depends on how the app is set up, so each
67
+ # platform states its own rather than sharing a guess.
68
+ def expected_webhook_object
69
+ "page"
70
+ end
71
+
72
+ private
73
+
74
+ def handle_webhook(context)
75
+ begin
76
+ parse_request_body(@controller.request)
77
+ rescue JSON::ParserError => e
78
+ FlowChat.logger.error { "#{log_tag}: Failed to parse webhook body: #{e.message}" }
79
+ return @controller.head :bad_request
80
+ end
81
+
82
+ is_simulator_mode = simulate?(context)
83
+ context["simulator_mode"] = true if is_simulator_mode
84
+
85
+ unless in_background? || is_simulator_mode || valid_webhook_signature?(@controller.request)
86
+ FlowChat.logger.warn { "#{log_tag}: Invalid webhook signature - dropping request" }
87
+ return @controller.head :ok
88
+ end
89
+
90
+ # Warn rather than debug. A gateway whose expected_webhook_object does not
91
+ # match what the app is actually subscribed to drops every delivery here
92
+ # and answers 200, so the symptom is a bot that receives nothing while the
93
+ # dashboard reports successful deliveries. That is worth a line in
94
+ # production logs, not one only visible at debug level.
95
+ if @body["object"].present? && @body["object"] != expected_webhook_object
96
+ FlowChat.logger.warn {
97
+ "#{log_tag}: Ignoring webhook for object '#{@body["object"]}', expected " \
98
+ "'#{expected_webhook_object}'. If every delivery lands here, this gateway's " \
99
+ "expected_webhook_object does not match the app's webhook subscription."
100
+ }
101
+ return @controller.head :ok
102
+ end
103
+
104
+ entries = @body["entry"]
105
+ unless entries.is_a?(Array) && entries.any?
106
+ return @controller.head :ok
107
+ end
108
+
109
+ # Only one event per delivery can drive a flow, because only one can own
110
+ # the response to this request.
111
+ flow_ran = false
112
+
113
+ entries.each do |entry|
114
+ # Published in the foreground only, and always - whether this turn
115
+ # goes on to run a flow inline or hand one to a background job.
116
+ #
117
+ # With async enabled the job re-enters this method on the same body,
118
+ # so publishing here as well as there announced every receipt, echo
119
+ # and standby event twice: once by the request, once by the job.
120
+ # Skipping them in the background leaves exactly one publisher, and
121
+ # leaves it the one that is already holding the delivery - so a
122
+ # receipt is announced when it arrives rather than whenever the queue
123
+ # gets to it, and survives a job that is never picked up.
124
+ #
125
+ # Nothing changes for an app that does not use async: it never runs
126
+ # in the background, so it takes this branch every time.
127
+ publish_side_events(entry) unless in_background?
128
+
129
+ events = entry["messaging"]
130
+ next unless events.is_a?(Array)
131
+
132
+ events.each do |event|
133
+ # Only a message or a postback drives a flow, which also rules out
134
+ # a receipt: it carries neither. An echo is the one thing that has
135
+ # to be named, because it does carry a message and would otherwise
136
+ # run a flow against our own outbound reply.
137
+ next unless drives_flow?(event)
138
+ next if echo?(event)
139
+
140
+ if flow_ran
141
+ FlowChat.logger.warn { "#{log_tag}: A second message arrived in the same delivery and was not processed" }
142
+ next
143
+ end
144
+ flow_ran = true
145
+
146
+ case handle_message(context, entry, event)
147
+ when :rejected then return @controller.head :forbidden
148
+ when :enqueued then return @controller.head :ok
149
+ when :rendered then return nil
150
+ end
151
+ end
152
+ end
153
+
154
+ @controller.head :ok
155
+ end
156
+
157
+ # Everything in a delivery that is not the one event driving a flow.
158
+ #
159
+ # Standby carries the same events for a thread another app owns, which is
160
+ # what a secondary receiver sees under the handover protocol. It is
161
+ # published whole and never run: a flow answering here would be talking
162
+ # over whoever Meta handed the thread to, and the send would be refused in
163
+ # any case. Whether to record it, and what to do about it, is the
164
+ # application's to decide.
165
+ #
166
+ # Nothing has to be subscribed for standby to arrive. A delivery switches
167
+ # to it the moment a business names another app the primary receiver, on
168
+ # the same subscription that was already in place.
169
+ def publish_side_events(entry)
170
+ Array(entry["standby"]).each do |event|
171
+ publish_standby(entry, event) if event.is_a?(Hash)
172
+ end
173
+
174
+ events = entry["messaging"]
175
+ return unless events.is_a?(Array)
176
+
177
+ # Receipts first, as they always were: a receipt arriving ahead of a
178
+ # message in the same batch must not be read as the message.
179
+ events.each { |event| handle_status(entry, event) if status_event?(event) }
180
+
181
+ events.each do |event|
182
+ next if status_event?(event)
183
+
184
+ if echo?(event)
185
+ publish_echo(entry, event)
186
+ elsif !drives_flow?(event)
187
+ publish_unmodelled(entry, event)
188
+ end
189
+ end
190
+ end
191
+
192
+ def status_event?(event)
193
+ event.key?("delivery") || event.key?("read")
194
+ end
195
+
196
+ def echo?(event)
197
+ event.dig("message", "is_echo") == true
198
+ end
199
+
200
+ def drives_flow?(event)
201
+ event.key?("message") || event.key?("postback")
202
+ end
203
+
204
+ def handle_message(context, entry, event)
205
+ account_id = entry["id"]
206
+
207
+ # Skipped in simulator mode rather than requiring the simulator to
208
+ # send an id that matches a real configuration: the simulator's
209
+ # whole point is running a turn with no live credentials at hand.
210
+ # Safe to skip, not a hole for real traffic, because
211
+ # context["simulator_mode"] is only ever true once simulate? has
212
+ # already checked the signed simulator cookie, above in
213
+ # handle_webhook.
214
+ expected = @config.webhook_account_id
215
+ ours = expected.present? && account_id.to_s == expected.to_s
216
+
217
+ unless context["simulator_mode"] || ours
218
+ FlowChat.logger.warn { "#{log_tag}: Webhook for account '#{account_id}' but configured for #{expected.inspect} - rejecting" }
219
+ return :rejected
220
+ end
221
+
222
+ sender_id = event.dig("sender", "id")
223
+ message = event["message"] || event["postback"]
224
+
225
+ context["request.id"] = sender_id
226
+ context["request.user_id"] = sender_id
227
+ context["request.msisdn"] = nil
228
+ context["request.message_id"] = message["mid"]
229
+ context["request.gateway"] = gateway_name
230
+ context["request.platform"] = platform
231
+ context["request.timestamp"] = Time.current.iso8601
232
+ context["request.body"] = @body
233
+
234
+ context["#{platform}.account.id"] = account_id
235
+ context["#{platform}.client"] = @client
236
+
237
+ extract_message_content!(event, context)
238
+
239
+ instrument(FlowChat::Instrumentation::Events::MESSAGE_RECEIVED, {
240
+ from: sender_id,
241
+ message: context.input,
242
+ message_type: event.key?("postback") ? "postback" : "message",
243
+ message_id: message["mid"]
244
+ })
245
+
246
+ if should_enqueue_async?
247
+ enqueue_async_job
248
+ return :enqueued
249
+ end
250
+
251
+ if context["simulator_mode"]
252
+ handle_message_simulator(context)
253
+ :rendered
254
+ else
255
+ handle_message_inline(context)
256
+ :processed
257
+ end
258
+ end
259
+
260
+ # A postback's payload, a quick reply's payload, and otherwise the text.
261
+ # An attachment-only turn has blank input, matching the media contract the
262
+ # other gateways follow.
263
+ def extract_message_content!(event, context)
264
+ if event.key?("postback")
265
+ context.input = event.dig("postback", "payload").to_s
266
+ return
267
+ end
268
+
269
+ message = event["message"]
270
+
271
+ if message["quick_reply"]
272
+ context.input = message.dig("quick_reply", "payload").to_s
273
+ return
274
+ end
275
+
276
+ attachments = message["attachments"]
277
+ if attachments.is_a?(Array) && attachments.any?
278
+ attachment = attachments.first
279
+
280
+ # Meta delivers a shared location as an attachment like any image, but
281
+ # every other gateway here puts one on request.location, and a flow
282
+ # reading app.location should not have to know which platform it is on.
283
+ if attachment["type"].to_s == "location"
284
+ coordinates = attachment.dig("payload", "coordinates") || {}
285
+ context["request.location"] = {
286
+ latitude: coordinates["lat"],
287
+ longitude: coordinates["long"],
288
+ name: attachment["title"]
289
+ }.compact
290
+ else
291
+ context["request.media"] = {
292
+ type: normalize_attachment_type(attachment["type"]),
293
+ url: attachment.dig("payload", "url")
294
+ }
295
+ end
296
+ end
297
+
298
+ context.input = message["text"].presence || ""
299
+ end
300
+
301
+ # "file" is Meta's name for what every other gateway here calls a document.
302
+ def normalize_attachment_type(type)
303
+ case type.to_s
304
+ when "file" then :document
305
+ when "" then nil
306
+ else type.to_s.to_sym
307
+ end
308
+ end
309
+
310
+ def handle_status(entry, event)
311
+ %w[delivery read].each do |kind|
312
+ payload = event[kind]
313
+ next unless payload
314
+
315
+ instrument(FlowChat::Instrumentation::Events::MESSAGE_STATUS, {
316
+ platform: platform,
317
+ gateway: gateway_name,
318
+ account_id: entry["id"],
319
+ recipient: event.dig("sender", "id"),
320
+ status: kind,
321
+ timestamp: event["timestamp"],
322
+ value: payload
323
+ })
324
+ end
325
+ end
326
+
327
+ # An echo reports a message sent on this thread by someone other than the
328
+ # user. Which someone decides what the application does about it: a human
329
+ # replying from the page inbox usually means the flow should stand down.
330
+ def publish_echo(entry, event)
331
+ instrument(FlowChat::Instrumentation::Events::WEBHOOK_RECEIVED, {
332
+ platform: platform,
333
+ gateway: gateway_name,
334
+ field: "message_echoes",
335
+ account_id: entry["id"],
336
+ echo_origin: echo_origin(event),
337
+ value: event
338
+ })
339
+ end
340
+
341
+ # Named for the channel rather than the event, because that is the part
342
+ # the application cannot work out for itself: a standby message looks
343
+ # exactly like an owned one, and only where it arrived says otherwise.
344
+ def publish_standby(entry, event)
345
+ FlowChat.logger.info { "#{log_tag}: Publishing standby event for account #{entry["id"]}" }
346
+
347
+ instrument(FlowChat::Instrumentation::Events::WEBHOOK_RECEIVED, {
348
+ platform: platform,
349
+ gateway: gateway_name,
350
+ field: "standby",
351
+ account_id: entry["id"],
352
+ value: event
353
+ })
354
+ end
355
+
356
+ def echo_origin(event)
357
+ app_id = event.dig("message", "app_id")
358
+
359
+ return :human_agent if app_id.blank?
360
+ return :self if app_id.to_s == @config.app_id.to_s
361
+
362
+ :other_app
363
+ end
364
+
365
+ # Everything that is not a message, its receipt, or an echo. Reactions,
366
+ # referrals, opt-ins, handovers, policy enforcement: all of it is the
367
+ # application's domain, so it is published whole rather than interpreted.
368
+ def publish_unmodelled(entry, event)
369
+ field = (event.keys - %w[sender recipient timestamp]).first
370
+
371
+ FlowChat.logger.info { "#{log_tag}: Publishing webhook event '#{field}'" }
372
+
373
+ instrument(FlowChat::Instrumentation::Events::WEBHOOK_RECEIVED, {
374
+ platform: platform,
375
+ gateway: gateway_name,
376
+ field: field,
377
+ account_id: entry["id"],
378
+ value: event
379
+ })
380
+ end
381
+
382
+ def handle_message_inline(context)
383
+ response = @app.call(context)
384
+ return unless response
385
+
386
+ type, prompt, choices, media = response
387
+
388
+ result = report_delivery_failure(
389
+ context,
390
+ to: context["request.user_id"],
391
+ session_id: context["request.id"],
392
+ message: prompt,
393
+ message_type: (type == :prompt) ? "prompt" : "terminal",
394
+ gateway: gateway_name,
395
+ platform: platform
396
+ ) do
397
+ @client.send_message(context["request.user_id"], prompt, choices: choices, media: media)
398
+ end
399
+
400
+ context["#{platform}.message_result"] = result
401
+
402
+ # report_delivery_failure already reported this; a nil result here
403
+ # means the platform did not accept the message, and instrumenting
404
+ # MESSAGE_SENT anyway counted a delivery that never happened.
405
+ return unless result
406
+
407
+ instrument(FlowChat::Instrumentation::Events::MESSAGE_SENT, {
408
+ to: context["request.user_id"],
409
+ session_id: context["request.id"],
410
+ message: prompt,
411
+ message_type: (type == :prompt) ? "prompt" : "terminal",
412
+ gateway: gateway_name,
413
+ platform: platform,
414
+ content_length: prompt.to_s.length,
415
+ platform_message_id: platform_message_id_from(result),
416
+ duration_ms: context[FlowChat::Instrumentation::DELIVERY_DURATION_KEY],
417
+ timestamp: context["request.timestamp"]
418
+ })
419
+ end
420
+
421
+ # The Send API answers with the id it assigned, flatter than WhatsApp's
422
+ # messages[0].id.
423
+ def platform_message_id_from(result)
424
+ return nil unless result.is_a?(Hash)
425
+
426
+ result["message_id"]
427
+ end
428
+
429
+ def handle_message_simulator(context)
430
+ response = @app.call(context)
431
+ return unless response
432
+
433
+ _, prompt, choices, media = response
434
+ rendered = renderer_class.new(prompt, choices: choices, media: media).render
435
+
436
+ @controller.render json: {
437
+ mode: "simulator",
438
+ webhook_processed: true,
439
+ would_send: rendered,
440
+ message_info: {
441
+ to: context["request.user_id"],
442
+ timestamp: Time.now.iso8601
443
+ }
444
+ }
445
+
446
+ nil
447
+ end
448
+
449
+ def simulate?(context)
450
+ return false unless context["enable_simulator"]
451
+
452
+ @body.dig("simulator_mode") &&
453
+ FlowChat::Security.valid_simulator_cookie?(@controller.request.cookies[FlowChat::Security::SIMULATOR_COOKIE_NAME])
454
+ end
455
+
456
+ def parse_request_body(request)
457
+ return @body if @body
458
+
459
+ @body = if request.body.nil?
460
+ {}
461
+ else
462
+ request.body.rewind if request.body.respond_to?(:rewind)
463
+ JSON.parse(request.body.read)
464
+ end
465
+ end
466
+ end
467
+ end
468
+ end
@@ -0,0 +1,30 @@
1
+ require "openssl"
2
+
3
+ module FlowChat
4
+ module Meta
5
+ # Whether a body carries Meta's signature for a given secret.
6
+ #
7
+ # The decision on its own, with no configuration, logging or gateway around
8
+ # it. Meta signs every product's webhook the same way, and an application
9
+ # that receives one outside a gateway needs this answer without the rest:
10
+ # a single endpoint serving several tenants has to verify the delivery
11
+ # before it can know whose it is, which is before it has a gateway to ask.
12
+ #
13
+ # SignatureValidation is the gateway's way in, and calls this.
14
+ module Signature
15
+ HEADER = "X-Hub-Signature-256"
16
+
17
+ # Total, rather than raising on a missing secret. A caller with no secret
18
+ # configured is not asking a different question; it is asking this one and
19
+ # the answer is no. A gateway that would rather treat that as the
20
+ # developer's mistake checks for it before asking.
21
+ def self.valid?(body, header, secret)
22
+ return false if secret.to_s.strip.empty? || header.to_s.empty?
23
+
24
+ expected = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, body.to_s)
25
+
26
+ FlowChat::Security.secure_compare(header.to_s.delete_prefix("sha256="), expected)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,66 @@
1
+ require "openssl"
2
+
3
+ module FlowChat
4
+ module Meta
5
+ # X-Hub-Signature-256 validation, shared by every Meta webhook gateway.
6
+ #
7
+ # The including gateway must have @config responding to #app_secret and
8
+ # #skip_signature_validation, and must include FlowChat::Meta::GatewayIdentity
9
+ # (directly or via another Meta:: behavior module) to supply platform_label
10
+ # and configuration_error_class.
11
+ #
12
+ # What it adds over FlowChat::Meta::Signature is the gateway's share: the
13
+ # opt out, treating a missing secret as the developer's mistake rather than
14
+ # an answer, reading the body off a Rack request, and the logging. An
15
+ # application holding Meta's webhooks outside a gateway wants Signature.
16
+ module SignatureValidation
17
+ include FlowChat::Meta::GatewayIdentity
18
+
19
+ private
20
+
21
+ def valid_webhook_signature?(request)
22
+ if @config.skip_signature_validation
23
+ FlowChat.logger.debug { "#{log_tag}: Webhook signature validation is disabled" }
24
+ return true
25
+ end
26
+
27
+ # Deliberately wider than a nil-or-empty check: a whitespace-only secret is
28
+ # never intentional, and computing an HMAC with it would silently accept
29
+ # traffic under a "secret" that offers no protection.
30
+ if @config.app_secret.blank?
31
+ error_msg = "#{platform_label} app_secret is required for webhook signature validation. " \
32
+ "Either configure app_secret or set skip_signature_validation=true to explicitly disable validation."
33
+ FlowChat.logger.error { "#{log_tag}: #{error_msg}" }
34
+ raise configuration_error_class, error_msg
35
+ end
36
+
37
+ signature_header = request.headers[FlowChat::Meta::Signature::HEADER]
38
+ unless signature_header
39
+ FlowChat.logger.warn { "#{log_tag}: No #{FlowChat::Meta::Signature::HEADER} header found in request" }
40
+ return false
41
+ end
42
+
43
+ request.body.rewind
44
+ body = request.body.read
45
+ request.body.rewind
46
+
47
+ signature_valid = FlowChat::Meta::Signature.valid?(body, signature_header, @config.app_secret)
48
+
49
+ if signature_valid
50
+ FlowChat.logger.debug { "#{log_tag}: Webhook signature validation successful" }
51
+ else
52
+ FlowChat.logger.warn { "#{log_tag}: Webhook signature validation failed - signatures do not match" }
53
+ end
54
+
55
+ signature_valid
56
+ rescue => e
57
+ # A misconfiguration is the developer's problem and must not be swallowed
58
+ # into a plain "invalid signature".
59
+ raise if e.is_a?(configuration_error_class)
60
+
61
+ FlowChat.logger.error { "#{log_tag}: Error validating webhook signature: #{e.class.name}: #{e.message}" }
62
+ false
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,43 @@
1
+ module FlowChat
2
+ module Meta
3
+ # The GET handshake Meta performs when a webhook URL is registered.
4
+ #
5
+ # The including gateway must have @config responding to #verify_token,
6
+ # @controller, and must include FlowChat::Meta::GatewayIdentity (directly or
7
+ # via another Meta:: behavior module) to supply #platform.
8
+ #
9
+ # What it adds over FlowChat::Meta::Challenge is the gateway's share:
10
+ # instrumenting the outcome and answering through the controller. An
11
+ # application holding Meta's webhooks outside a gateway wants Challenge.
12
+ module WebhookVerification
13
+ include FlowChat::Meta::GatewayIdentity
14
+
15
+ private
16
+
17
+ def handle_verification(context)
18
+ params = @controller.request.params
19
+
20
+ challenge = FlowChat::Meta::Challenge.answer(params, @config.verify_token)
21
+ verified = !challenge.nil?
22
+
23
+ FlowChat.logger.debug { "#{log_tag}: Webhook verification - provided token matches: #{verified}" }
24
+
25
+ if verified
26
+ instrument(FlowChat::Instrumentation::Events::WEBHOOK_VERIFIED, {
27
+ challenge: challenge,
28
+ platform: platform
29
+ })
30
+
31
+ @controller.render plain: challenge
32
+ else
33
+ instrument(FlowChat::Instrumentation::Events::WEBHOOK_FAILED, {
34
+ reason: "Invalid verify token",
35
+ platform: platform
36
+ })
37
+
38
+ @controller.head :forbidden
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,65 @@
1
+ module FlowChat
2
+ # The named-configuration registry every platform's Configuration shares.
3
+ #
4
+ # Storage is a class-level ivar on the including class, not a class variable.
5
+ # A @@configurations in a shared module would give every platform one merged
6
+ # registry, so a name registered for Messenger would resolve for WhatsApp.
7
+ module NamedConfiguration
8
+ def self.included(base)
9
+ base.extend(ClassMethods)
10
+ end
11
+
12
+ module ClassMethods
13
+ def register(name, config)
14
+ FlowChat.logger.debug { "#{self.name}: Registering configuration '#{name}'" }
15
+ configurations[name.to_sym] = config
16
+ end
17
+
18
+ def get(name)
19
+ config = configurations[name.to_sym]
20
+ unless config
21
+ FlowChat.logger.error { "#{self.name}: Configuration '#{name}' not found" }
22
+ raise ArgumentError, "#{configuration_label} configuration '#{name}' not found"
23
+ end
24
+
25
+ FlowChat.logger.debug { "#{self.name}: Retrieved configuration '#{name}'" }
26
+ config
27
+ end
28
+
29
+ def exists?(name)
30
+ configurations.key?(name.to_sym)
31
+ end
32
+
33
+ def configuration_names
34
+ configurations.keys
35
+ end
36
+
37
+ def clear_all!
38
+ FlowChat.logger.debug { "#{name}: Clearing all registered configurations" }
39
+ configurations.clear
40
+ end
41
+
42
+ # The platform's name as it appears in the not-found message. Overridden
43
+ # where the constant name and the product name differ, as with WhatsApp.
44
+ def configuration_label
45
+ name.split("::")[-2]
46
+ end
47
+
48
+ private
49
+
50
+ # The registry itself is not API. It replaced a @@configurations class
51
+ # variable, which was equally internal, and configuration_names is the
52
+ # public way to ask what is registered.
53
+ def configurations
54
+ @configurations ||= {}
55
+ end
56
+ end
57
+
58
+ def register_as(name)
59
+ FlowChat.logger.debug { "#{self.class.name}: Registering configuration as '#{name}'" }
60
+ @name = name.to_sym
61
+ self.class.register(@name, self)
62
+ self
63
+ end
64
+ end
65
+ end