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
@@ -0,0 +1,471 @@
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 by whichever pass is first to see this delivery, and
115
+ # only once.
116
+ #
117
+ # With async enabled the gem publishes here, enqueues, and the job
118
+ # re-enters this method on the same body, so publishing in both
119
+ # announced every receipt, echo and standby event twice. The job the
120
+ # gem enqueues therefore carries word that they are already out, and
121
+ # skips them. The request is the better publisher of the two: it
122
+ # already holds the delivery, so a receipt is announced when it
123
+ # arrives rather than whenever the queue gets to it, and survives a
124
+ # job that is never picked up.
125
+ #
126
+ # A background pass with nothing behind it publishes. An application
127
+ # is free to build its own request context and enqueue a job itself,
128
+ # which is what fanning one shared webhook out to several accounts
129
+ # takes, and no foreground pass ever runs for those.
130
+ publish_side_events(entry) unless side_events_already_published?
131
+
132
+ events = entry["messaging"]
133
+ next unless events.is_a?(Array)
134
+
135
+ events.each do |event|
136
+ # Only a message or a postback drives a flow, which also rules out
137
+ # a receipt: it carries neither. An echo is the one thing that has
138
+ # to be named, because it does carry a message and would otherwise
139
+ # run a flow against our own outbound reply.
140
+ next unless drives_flow?(event)
141
+ next if echo?(event)
142
+
143
+ if flow_ran
144
+ FlowChat.logger.warn { "#{log_tag}: A second message arrived in the same delivery and was not processed" }
145
+ next
146
+ end
147
+ flow_ran = true
148
+
149
+ case handle_message(context, entry, event)
150
+ when :rejected then return @controller.head :forbidden
151
+ when :enqueued then return @controller.head :ok
152
+ when :rendered then return nil
153
+ end
154
+ end
155
+ end
156
+
157
+ @controller.head :ok
158
+ end
159
+
160
+ # Everything in a delivery that is not the one event driving a flow.
161
+ #
162
+ # Standby carries the same events for a thread another app owns, which is
163
+ # what a secondary receiver sees under the handover protocol. It is
164
+ # published whole and never run: a flow answering here would be talking
165
+ # over whoever Meta handed the thread to, and the send would be refused in
166
+ # any case. Whether to record it, and what to do about it, is the
167
+ # application's to decide.
168
+ #
169
+ # Nothing has to be subscribed for standby to arrive. A delivery switches
170
+ # to it the moment a business names another app the primary receiver, on
171
+ # the same subscription that was already in place.
172
+ def publish_side_events(entry)
173
+ Array(entry["standby"]).each do |event|
174
+ publish_standby(entry, event) if event.is_a?(Hash)
175
+ end
176
+
177
+ events = entry["messaging"]
178
+ return unless events.is_a?(Array)
179
+
180
+ # Receipts first, as they always were: a receipt arriving ahead of a
181
+ # message in the same batch must not be read as the message.
182
+ events.each { |event| handle_status(entry, event) if status_event?(event) }
183
+
184
+ events.each do |event|
185
+ next if status_event?(event)
186
+
187
+ if echo?(event)
188
+ publish_echo(entry, event)
189
+ elsif !drives_flow?(event)
190
+ publish_unmodelled(entry, event)
191
+ end
192
+ end
193
+ end
194
+
195
+ def status_event?(event)
196
+ event.key?("delivery") || event.key?("read")
197
+ end
198
+
199
+ def echo?(event)
200
+ event.dig("message", "is_echo") == true
201
+ end
202
+
203
+ def drives_flow?(event)
204
+ event.key?("message") || event.key?("postback")
205
+ end
206
+
207
+ def handle_message(context, entry, event)
208
+ account_id = entry["id"]
209
+
210
+ # Skipped in simulator mode rather than requiring the simulator to
211
+ # send an id that matches a real configuration: the simulator's
212
+ # whole point is running a turn with no live credentials at hand.
213
+ # Safe to skip, not a hole for real traffic, because
214
+ # context["simulator_mode"] is only ever true once simulate? has
215
+ # already checked the signed simulator cookie, above in
216
+ # handle_webhook.
217
+ expected = @config.webhook_account_id
218
+ ours = expected.present? && account_id.to_s == expected.to_s
219
+
220
+ unless context["simulator_mode"] || ours
221
+ FlowChat.logger.warn { "#{log_tag}: Webhook for account '#{account_id}' but configured for #{expected.inspect} - rejecting" }
222
+ return :rejected
223
+ end
224
+
225
+ sender_id = event.dig("sender", "id")
226
+ message = event["message"] || event["postback"]
227
+
228
+ context["request.id"] = sender_id
229
+ context["request.user_id"] = sender_id
230
+ context["request.msisdn"] = nil
231
+ context["request.message_id"] = message["mid"]
232
+ context["request.gateway"] = gateway_name
233
+ context["request.platform"] = platform
234
+ context["request.timestamp"] = Time.current.iso8601
235
+ context["request.body"] = @body
236
+
237
+ context["#{platform}.account.id"] = account_id
238
+ context["#{platform}.client"] = @client
239
+
240
+ extract_message_content!(event, context)
241
+
242
+ instrument(FlowChat::Instrumentation::Events::MESSAGE_RECEIVED, {
243
+ from: sender_id,
244
+ message: context.input,
245
+ message_type: event.key?("postback") ? "postback" : "message",
246
+ message_id: message["mid"]
247
+ })
248
+
249
+ if should_enqueue_async?
250
+ enqueue_async_job
251
+ return :enqueued
252
+ end
253
+
254
+ if context["simulator_mode"]
255
+ handle_message_simulator(context)
256
+ :rendered
257
+ else
258
+ handle_message_inline(context)
259
+ :processed
260
+ end
261
+ end
262
+
263
+ # A postback's payload, a quick reply's payload, and otherwise the text.
264
+ # An attachment-only turn has blank input, matching the media contract the
265
+ # other gateways follow.
266
+ def extract_message_content!(event, context)
267
+ if event.key?("postback")
268
+ context.input = event.dig("postback", "payload").to_s
269
+ return
270
+ end
271
+
272
+ message = event["message"]
273
+
274
+ if message["quick_reply"]
275
+ context.input = message.dig("quick_reply", "payload").to_s
276
+ return
277
+ end
278
+
279
+ attachments = message["attachments"]
280
+ if attachments.is_a?(Array) && attachments.any?
281
+ attachment = attachments.first
282
+
283
+ # Meta delivers a shared location as an attachment like any image, but
284
+ # every other gateway here puts one on request.location, and a flow
285
+ # reading app.location should not have to know which platform it is on.
286
+ if attachment["type"].to_s == "location"
287
+ coordinates = attachment.dig("payload", "coordinates") || {}
288
+ context["request.location"] = {
289
+ latitude: coordinates["lat"],
290
+ longitude: coordinates["long"],
291
+ name: attachment["title"]
292
+ }.compact
293
+ else
294
+ context["request.media"] = {
295
+ type: normalize_attachment_type(attachment["type"]),
296
+ url: attachment.dig("payload", "url")
297
+ }
298
+ end
299
+ end
300
+
301
+ context.input = message["text"].presence || ""
302
+ end
303
+
304
+ # "file" is Meta's name for what every other gateway here calls a document.
305
+ def normalize_attachment_type(type)
306
+ case type.to_s
307
+ when "file" then :document
308
+ when "" then nil
309
+ else type.to_s.to_sym
310
+ end
311
+ end
312
+
313
+ def handle_status(entry, event)
314
+ %w[delivery read].each do |kind|
315
+ payload = event[kind]
316
+ next unless payload
317
+
318
+ instrument(FlowChat::Instrumentation::Events::MESSAGE_STATUS, {
319
+ platform: platform,
320
+ gateway: gateway_name,
321
+ account_id: entry["id"],
322
+ recipient: event.dig("sender", "id"),
323
+ status: kind,
324
+ timestamp: event["timestamp"],
325
+ value: payload
326
+ })
327
+ end
328
+ end
329
+
330
+ # An echo reports a message sent on this thread by someone other than the
331
+ # user. Which someone decides what the application does about it: a human
332
+ # replying from the page inbox usually means the flow should stand down.
333
+ def publish_echo(entry, event)
334
+ instrument(FlowChat::Instrumentation::Events::WEBHOOK_RECEIVED, {
335
+ platform: platform,
336
+ gateway: gateway_name,
337
+ field: "message_echoes",
338
+ account_id: entry["id"],
339
+ echo_origin: echo_origin(event),
340
+ value: event
341
+ })
342
+ end
343
+
344
+ # Named for the channel rather than the event, because that is the part
345
+ # the application cannot work out for itself: a standby message looks
346
+ # exactly like an owned one, and only where it arrived says otherwise.
347
+ def publish_standby(entry, event)
348
+ FlowChat.logger.info { "#{log_tag}: Publishing standby event for account #{entry["id"]}" }
349
+
350
+ instrument(FlowChat::Instrumentation::Events::WEBHOOK_RECEIVED, {
351
+ platform: platform,
352
+ gateway: gateway_name,
353
+ field: "standby",
354
+ account_id: entry["id"],
355
+ value: event
356
+ })
357
+ end
358
+
359
+ def echo_origin(event)
360
+ app_id = event.dig("message", "app_id")
361
+
362
+ return :human_agent if app_id.blank?
363
+ return :self if app_id.to_s == @config.app_id.to_s
364
+
365
+ :other_app
366
+ end
367
+
368
+ # Everything that is not a message, its receipt, or an echo. Reactions,
369
+ # referrals, opt-ins, handovers, policy enforcement: all of it is the
370
+ # application's domain, so it is published whole rather than interpreted.
371
+ def publish_unmodelled(entry, event)
372
+ field = (event.keys - %w[sender recipient timestamp]).first
373
+
374
+ FlowChat.logger.info { "#{log_tag}: Publishing webhook event '#{field}'" }
375
+
376
+ instrument(FlowChat::Instrumentation::Events::WEBHOOK_RECEIVED, {
377
+ platform: platform,
378
+ gateway: gateway_name,
379
+ field: field,
380
+ account_id: entry["id"],
381
+ value: event
382
+ })
383
+ end
384
+
385
+ def handle_message_inline(context)
386
+ response = @app.call(context)
387
+ return unless response
388
+
389
+ type, prompt, choices, media = response
390
+
391
+ result = report_delivery_failure(
392
+ context,
393
+ to: context["request.user_id"],
394
+ session_id: context["request.id"],
395
+ message: prompt,
396
+ message_type: (type == :prompt) ? "prompt" : "terminal",
397
+ gateway: gateway_name,
398
+ platform: platform
399
+ ) do
400
+ @client.send_message(context["request.user_id"], prompt, choices: choices, media: media)
401
+ end
402
+
403
+ context["#{platform}.message_result"] = result
404
+
405
+ # report_delivery_failure already reported this; a nil result here
406
+ # means the platform did not accept the message, and instrumenting
407
+ # MESSAGE_SENT anyway counted a delivery that never happened.
408
+ return unless result
409
+
410
+ instrument(FlowChat::Instrumentation::Events::MESSAGE_SENT, {
411
+ to: context["request.user_id"],
412
+ session_id: context["request.id"],
413
+ message: prompt,
414
+ message_type: (type == :prompt) ? "prompt" : "terminal",
415
+ gateway: gateway_name,
416
+ platform: platform,
417
+ content_length: prompt.to_s.length,
418
+ platform_message_id: platform_message_id_from(result),
419
+ duration_ms: context[FlowChat::Instrumentation::DELIVERY_DURATION_KEY],
420
+ timestamp: context["request.timestamp"]
421
+ })
422
+ end
423
+
424
+ # The Send API answers with the id it assigned, flatter than WhatsApp's
425
+ # messages[0].id.
426
+ def platform_message_id_from(result)
427
+ return nil unless result.is_a?(Hash)
428
+
429
+ result["message_id"]
430
+ end
431
+
432
+ def handle_message_simulator(context)
433
+ response = @app.call(context)
434
+ return unless response
435
+
436
+ _, prompt, choices, media = response
437
+ rendered = renderer_class.new(prompt, choices: choices, media: media).render
438
+
439
+ @controller.render json: {
440
+ mode: "simulator",
441
+ webhook_processed: true,
442
+ would_send: rendered,
443
+ message_info: {
444
+ to: context["request.user_id"],
445
+ timestamp: Time.now.iso8601
446
+ }
447
+ }
448
+
449
+ nil
450
+ end
451
+
452
+ def simulate?(context)
453
+ return false unless context["enable_simulator"]
454
+
455
+ @body.dig("simulator_mode") &&
456
+ FlowChat::Security.valid_simulator_cookie?(@controller.request.cookies[FlowChat::Security::SIMULATOR_COOKIE_NAME])
457
+ end
458
+
459
+ def parse_request_body(request)
460
+ return @body if @body
461
+
462
+ @body = if request.body.nil?
463
+ {}
464
+ else
465
+ request.body.rewind if request.body.respond_to?(:rewind)
466
+ JSON.parse(request.body.read)
467
+ end
468
+ end
469
+ end
470
+ end
471
+ 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