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
@@ -3,9 +3,6 @@ require "openssl"
3
3
 
4
4
  module FlowChat
5
5
  module Intercom
6
- # Configuration-related errors
7
- class ConfigurationError < StandardError; end
8
-
9
6
  module Gateway
10
7
  class IntercomApi
11
8
  include FlowChat::Instrumentation
@@ -16,6 +13,17 @@ module FlowChat
16
13
  # Default webhook topics to process
17
14
  DEFAULT_WEBHOOK_TOPICS = ["conversation.user.created", "conversation.user.replied"].freeze
18
15
 
16
+ # Configure Intercom specific middleware stack
17
+ def self.configure_middleware_stack(builder, custom_middleware)
18
+ FlowChat.logger.debug { "IntercomApi: Configuring Intercom middleware stack" }
19
+
20
+ builder.use custom_middleware
21
+ FlowChat.logger.debug { "IntercomApi: Added custom middleware" }
22
+
23
+ builder.use FlowChat::Intercom::Middleware::ChoiceMapper
24
+ FlowChat.logger.debug { "IntercomApi: Added Intercom::Middleware::ChoiceMapper" }
25
+ end
26
+
19
27
  def initialize(app, config = nil, additional_webhook_topics = nil)
20
28
  @app = app
21
29
  @config = config || FlowChat::Intercom::Configuration.from_credentials
@@ -141,10 +149,18 @@ module FlowChat
141
149
 
142
150
  if latest_message
143
151
  context["request.message_id"] = latest_message[:id]
144
- # Convert HTML to markdown for message body
145
- raw_body = latest_message[:body] || ""
146
- context.input = @client.parse_message(raw_body)
147
- FlowChat.logger.debug { "IntercomApi: Message content extracted - Event: #{event_type}, Input: '#{context.input}'" }
152
+ if latest_message[:media]
153
+ media = latest_message[:media]
154
+ body = latest_message[:body]
155
+ context["request.media"] = media
156
+ context.input = body.present? ? @client.parse_message(body) : ""
157
+ FlowChat.logger.debug { "IntercomApi: Media received - #{media.size} attachment(s)" }
158
+ else
159
+ # Convert HTML to markdown for message body
160
+ raw_body = latest_message[:body] || ""
161
+ context.input = @client.parse_message(raw_body)
162
+ FlowChat.logger.debug { "IntercomApi: Message content extracted - Event: #{event_type}, Input: '#{context.input}'" }
163
+ end
148
164
  elsif @allowed_webhook_topics.include?(event_type)
149
165
  # No message but event is explicitly allowed - process without message
150
166
  context.input = nil
@@ -237,7 +253,7 @@ module FlowChat
237
253
  )
238
254
 
239
255
  # Compare signatures using secure comparison to prevent timing attacks
240
- signature_valid = secure_compare(expected_signature, calculated_signature)
256
+ signature_valid = FlowChat::Security.secure_compare(expected_signature, calculated_signature)
241
257
 
242
258
  if signature_valid
243
259
  FlowChat.logger.debug { "IntercomApi: Webhook signature validation successful" }
@@ -253,16 +269,6 @@ module FlowChat
253
269
  false
254
270
  end
255
271
 
256
- # Secure string comparison to prevent timing attacks
257
- def secure_compare(a, b)
258
- return false unless a.bytesize == b.bytesize
259
-
260
- l = a.unpack("C*")
261
- res = 0
262
- b.each_byte { |byte| res |= byte ^ l.shift }
263
- res == 0
264
- end
265
-
266
272
  def extract_latest_user_message(conversation, event_type)
267
273
  FlowChat.logger.debug { "IntercomApi: Extracting latest user message from #{event_type} event" }
268
274
 
@@ -270,11 +276,12 @@ module FlowChat
270
276
  when "conversation.user.created"
271
277
  # For new conversations, get the initial message from source
272
278
  source = conversation["source"]
273
- if source && source["body"]
279
+ if source && (source["body"] || source["attachments"]&.any?)
274
280
  {
275
281
  id: source["id"],
276
- body: source["body"]
277
- }
282
+ body: source["body"],
283
+ media: extract_attachments(source)
284
+ }.compact
278
285
  end
279
286
  when "conversation.user.replied"
280
287
  # For replies, get the latest user message from conversation_parts
@@ -291,19 +298,57 @@ module FlowChat
291
298
  latest_part = user_parts.last
292
299
  {
293
300
  id: latest_part["id"],
294
- body: latest_part["body"]
295
- }
301
+ body: latest_part["body"],
302
+ media: extract_attachments(latest_part)
303
+ }.compact
296
304
  end
297
305
  end
298
306
  end
299
307
 
308
+ def extract_attachments(raw)
309
+ attachments = raw["attachments"] || []
310
+ return nil if attachments.empty?
311
+
312
+ attachments.map do |a|
313
+ {
314
+ type: intercom_media_type(a["content_type"]),
315
+ url: a["url"],
316
+ mime_type: a["content_type"],
317
+ filename: a["name"]
318
+ }
319
+ end
320
+ end
321
+
322
+ def intercom_media_type(content_type)
323
+ case content_type
324
+ when %r{\Aimage/} then :image
325
+ when %r{\Avideo/} then :video
326
+ when %r{\Aaudio/} then :audio
327
+ else :document
328
+ end
329
+ end
330
+
300
331
  def handle_message_inline(context, controller)
301
332
  response = @app.call(context)
302
333
  if response
303
334
  _type, prompt, choices, media = response
304
- result = @client.send_message(context["request.id"], prompt, choices: choices, media: media)
335
+ result = report_delivery_failure(
336
+ context,
337
+ to: context["request.user_id"],
338
+ conversation_id: context["request.id"],
339
+ message: prompt,
340
+ gateway: :intercom_api,
341
+ platform: :intercom
342
+ ) do
343
+ @client.send_message(context["request.id"], prompt, choices: choices, media: media)
344
+ end
305
345
  context["intercom.message_result"] = result
306
346
 
347
+ # report_delivery_failure already reported this; a nil result
348
+ # means the platform did not accept the message, and instrumenting
349
+ # MESSAGE_SENT anyway counted a delivery that never happened.
350
+ return unless result
351
+
307
352
  # Instrument message sent
308
353
  instrument(Events::MESSAGE_SENT, {
309
354
  to: context["request.user_id"],
@@ -312,11 +357,20 @@ module FlowChat
312
357
  gateway: :intercom_api,
313
358
  platform: :intercom,
314
359
  content_length: prompt.to_s.length,
360
+ platform_message_id: platform_message_id_from(result),
361
+ duration_ms: context[FlowChat::Instrumentation::DELIVERY_DURATION_KEY],
315
362
  timestamp: context["request.timestamp"]
316
363
  })
317
364
  end
318
365
  end
319
366
 
367
+ # A reply comes back as the conversation part it created.
368
+ def platform_message_id_from(result)
369
+ return nil unless result.is_a?(Hash)
370
+
371
+ result["id"]
372
+ end
373
+
320
374
  def handle_message_simulator(context, controller)
321
375
  response = @app.call(context)
322
376
 
@@ -348,39 +402,9 @@ module FlowChat
348
402
  # Check if simulator mode is enabled for this processor
349
403
  return false unless context["enable_simulator"]
350
404
 
351
- # Then check if simulator mode is requested and valid
352
- @body.dig("simulator_mode") && valid_simulator_cookie?(context)
353
- end
354
-
355
- def valid_simulator_cookie?(context)
356
- simulator_secret = FlowChat::Config.simulator_secret
357
- return false unless simulator_secret && !simulator_secret.empty?
358
-
359
- # Check for simulator cookie
360
- simulator_cookie = @controller.request.cookies["flowchat_simulator"]
361
- return false unless simulator_cookie
362
-
363
- # Verify the cookie is a valid HMAC signature
364
- # Cookie format: "timestamp:signature" where signature = HMAC(simulator_secret, "simulator:timestamp")
365
- begin
366
- timestamp_str, signature = simulator_cookie.split(":", 2)
367
- return false unless timestamp_str && signature
368
-
369
- # Check timestamp is recent (within 24 hours for reasonable session duration)
370
- timestamp = timestamp_str.to_i
371
- return false if timestamp <= 0
372
- return false if (Time.now.to_i - timestamp).abs > 86400 # 24 hours
373
-
374
- # Calculate expected signature
375
- message = "simulator:#{timestamp_str}"
376
- expected_signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), simulator_secret, message)
377
-
378
- # Use secure comparison
379
- secure_compare(signature, expected_signature)
380
- rescue => e
381
- Rails.logger.warn "Invalid simulator cookie format: #{e.message}"
382
- false
383
- end
405
+ # Then check if simulator mode is requested and authorized
406
+ @body.dig("simulator_mode") &&
407
+ FlowChat::Security.valid_simulator_cookie?(@controller.request.cookies[FlowChat::Security::SIMULATOR_COOKIE_NAME])
384
408
  end
385
409
 
386
410
  def parse_request_body(request)
@@ -0,0 +1,101 @@
1
+ module FlowChat
2
+ module Intercom
3
+ module Middleware
4
+ # Maps an Intercom reply back to the choice it belongs to.
5
+ #
6
+ # Intercom has no interactive buttons, so the renderer writes the options
7
+ # out as a numbered list and asks the reader to reply with a number. That
8
+ # number has to come back to the choice key the flow branches on, the same
9
+ # job USSD's mapper does for a handset.
10
+ #
11
+ # A number is the only thing that resolves, as on USSD, and that is what
12
+ # the message asks for in as many words. It is also what makes this
13
+ # mapper safe by construction rather than by care: positions are unique
14
+ # whatever the labels say, so there is no equivalence under which two
15
+ # choices could collapse and nothing to check them against.
16
+ #
17
+ # Labels were matched too, case insensitively. That is what needed the
18
+ # care - two choices reading the same, or differing only in case, folded
19
+ # onto one entry and the second could not be picked by name at all. On a
20
+ # screen that prints a number beside every option and asks for one, the
21
+ # number already does that job unambiguously.
22
+ #
23
+ # Flow:
24
+ # 1. Flow returns choices with their own keys (e.g. {"sales" => "Sales"})
25
+ # 2. This middleware numbers them and remembers what each number meant
26
+ # 3. The renderer writes "1. Sales" and asks for a number
27
+ # 4. The reader replies "1"
28
+ # 5. This middleware turns it back into "sales"
29
+ # 6. The flow sees its own key
30
+ class ChoiceMapper
31
+ SESSION_KEY = "intercom.choice_mapping"
32
+
33
+ def initialize(app)
34
+ @app = app
35
+ FlowChat.logger.debug { "Intercom::ChoiceMapper: Initialized Intercom choice mapping middleware" }
36
+ end
37
+
38
+ def call(context)
39
+ @context = context
40
+ @session = context.session
41
+
42
+ resolve_input
43
+
44
+ type, prompt, choices, media = @app.call(context)
45
+
46
+ choices = remember(choices)
47
+
48
+ [type, prompt, choices, media]
49
+ end
50
+
51
+ private
52
+
53
+ def resolve_input
54
+ return if @context.input.blank?
55
+
56
+ mapping = @session.get(SESSION_KEY) || {}
57
+ return if mapping.empty?
58
+
59
+ # Surrounding whitespace is trimmed off a chat message before the
60
+ # lookup. Unlike a fold over labels, this cannot merge two choices:
61
+ # trimming leaves "1" and "2" as distinct as it found them.
62
+ matched = mapping[@context.input.to_s.strip]
63
+ return unless matched
64
+
65
+ FlowChat.logger.info { "Intercom::ChoiceMapper: Resolving #{@context.input} to #{matched}" }
66
+ @context.input = matched
67
+ end
68
+
69
+ # Numbers the choices and stores what each number meant.
70
+ #
71
+ # The renderer is what prints the number beside each label, so nothing
72
+ # is prefixed here - doing both would read as "1. 1. Savings".
73
+ #
74
+ # Cleared when a screen carries no choices, so an answer typed into a
75
+ # later free-text question is never read as a choice from an earlier
76
+ # menu. WhatsApp and Messenger each had this same bug fixed twice.
77
+ #
78
+ # @return [Hash, nil] the choices to render
79
+ def remember(choices)
80
+ if choices.blank?
81
+ @session.delete(SESSION_KEY)
82
+ return choices
83
+ end
84
+
85
+ numbered = {}
86
+ mapping = {}
87
+
88
+ choices.each_with_index do |(key, label), index|
89
+ number = (index + 1).to_s
90
+ numbered[number] = label.to_s
91
+ mapping[number] = key.to_s
92
+ end
93
+
94
+ @session.set(SESSION_KEY, mapping)
95
+ FlowChat.logger.debug { "Intercom::ChoiceMapper: Created mapping: #{mapping}" }
96
+ numbered
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -24,7 +24,8 @@ module FlowChat
24
24
  private
25
25
 
26
26
  def build_text_message
27
- [:text, to_html(message), {}]
27
+ link, options = render_media
28
+ [:text, to_html(message.to_s + link), options]
28
29
  end
29
30
 
30
31
  def build_selection_message
@@ -35,10 +36,27 @@ module FlowChat
35
36
  end
36
37
  end
37
38
 
39
+ # Choices are a numbered list rather than Intercom's own quick replies,
40
+ # and that is a decision rather than an oversight. Intercom does document
41
+ # reply_options with message_type "quick_reply" on an admin reply, and it
42
+ # was built here and reverted. Three reasons, in the order they matter:
43
+ #
44
+ # - The uuid of a clicked option comes back as quick_reply_option_uuid,
45
+ # and only if the Intercom app is set to the *Unstable* API version.
46
+ # Webhooks inherit that setting, so an app on a stable version receives
47
+ # no metadata at all. Requiring an unstable API version for something as
48
+ # basic as reading which option was chosen is not a thing a library can
49
+ # ask of its users.
50
+ # - body is forbidden on a quick_reply, so a choice screen becomes two
51
+ # conversation parts: one for the prompt, one for the buttons. In an
52
+ # inbox a human reads, that doubles the length of every flow.
53
+ # - Intercom's own community reports the endpoint returning errors for
54
+ # this shape.
55
+ #
56
+ # A numbered list needs none of that and works on every API version. If
57
+ # Intercom stabilises quick replies, the git history has the
58
+ # implementation; check those three things before restoring it.
38
59
  def build_interactive_message(choice_hash)
39
- # For Intercom, we'll present choices as a formatted text message
40
- # since Intercom doesn't have the same interactive elements as WhatsApp
41
-
42
60
  formatted_message = message.to_s
43
61
 
44
62
  unless formatted_message.empty?
@@ -53,7 +71,41 @@ module FlowChat
53
71
 
54
72
  formatted_message += "\nReply with the number of your choice."
55
73
 
56
- [:text, to_html(formatted_message), {choices: choice_hash}]
74
+ link, options = render_media
75
+ [:text, to_html(formatted_message + link), options.merge(choices: choice_hash)]
76
+ end
77
+
78
+ # Intercom's admin reply only takes real attachments as image URLs
79
+ # (attachment_urls, documented specifically for images, max 10). Any
80
+ # other media type with a url - document, video, audio, sticker -
81
+ # becomes a markdown link in the body instead, since attachment_urls
82
+ # is documented for images and a non-image URL there may not render.
83
+ # An id with no url is another platform's upload handle (a WhatsApp
84
+ # media id, say) and means nothing to Intercom, so it is logged and
85
+ # dropped rather than raised: a multi-platform flow legitimately sets
86
+ # an id for whichever platform uploaded it, and one platform lacking
87
+ # the media should not fail the whole turn.
88
+ #
89
+ # Returns [markdown_suffix, options] - the suffix is appended to the
90
+ # message before markdown-to-HTML conversion so a link goes through
91
+ # the same sanitizer and allowed_tags as the rest of the body.
92
+ def render_media
93
+ return ["", {}] unless media
94
+
95
+ url = media[:url]
96
+ media_type = (media[:type] || :image).to_sym
97
+
98
+ unless url
99
+ FlowChat.logger.warn { "Intercom::Renderer: media id #{media[:id].inspect} is another platform's upload handle and means nothing to Intercom (no url given); sending the message without it" }
100
+ return ["", {}]
101
+ end
102
+
103
+ if media_type == :image
104
+ ["", {attachment_urls: [url]}]
105
+ else
106
+ label = media[:filename] || media_type.to_s.capitalize
107
+ ["\n\n[#{label}](#{url})", {}]
108
+ end
57
109
  end
58
110
 
59
111
  # MarkdownSupport overrides for Intercom-specific behavior
@@ -0,0 +1,121 @@
1
+ require "net/http"
2
+ require "uri"
3
+
4
+ module FlowChat
5
+ # Value object wrapping a single inbound media item parsed by a gateway.
6
+ # Normalizes cross-platform differences (WhatsApp media-id, Telegram file_id,
7
+ # Intercom/HTTP direct URL) behind #url and #download.
8
+ class Media
9
+ # Maps platform-native media types to a canonical, cross-platform set.
10
+ # Telegram uses :photo/:voice where WhatsApp uses :image/:audio.
11
+ NORMALIZED_TYPES = {photo: :image, voice: :audio}.freeze
12
+
13
+ # The canonical media types FlowChat recognizes across platforms. Gateways
14
+ # that accept a caller-supplied type (e.g. HTTP) validate against this set.
15
+ CANONICAL_TYPES = %i[image video audio document sticker].freeze
16
+
17
+ attr_reader :platform, :client
18
+
19
+ def initialize(data, platform:, client: nil)
20
+ @data = data
21
+ @platform = platform
22
+ @client = client
23
+ end
24
+
25
+ # Canonical, cross-platform media type (:image, :video, :audio, :document, :sticker).
26
+ def type
27
+ NORMALIZED_TYPES.fetch(raw_type, raw_type)
28
+ end
29
+
30
+ # The platform-native type as parsed by the gateway (e.g. :photo, :voice on Telegram).
31
+ def raw_type
32
+ @data[:type]
33
+ end
34
+
35
+ def mime_type
36
+ @data[:mime_type]
37
+ end
38
+
39
+ def caption
40
+ @data[:caption]
41
+ end
42
+
43
+ def filename
44
+ @data[:filename] || @data[:file_name]
45
+ end
46
+
47
+ def id
48
+ @data[:id]
49
+ end
50
+
51
+ def file_id
52
+ @data[:file_id]
53
+ end
54
+
55
+ def [](key)
56
+ @data[key]
57
+ end
58
+
59
+ def to_h
60
+ @data.dup
61
+ end
62
+
63
+ # Serialize without the live platform client (a network object that may not
64
+ # marshal). This keeps a Media — and any FlowChat::Input holding it — safe to
65
+ # store in a session store. A deserialized Media has no client, so #url and
66
+ # #download degrade to nil rather than raising.
67
+ def marshal_dump
68
+ {data: @data, platform: @platform}
69
+ end
70
+
71
+ def marshal_load(state)
72
+ @data = state[:data]
73
+ @platform = state[:platform]
74
+ @client = nil
75
+ end
76
+
77
+ # Resolve a fetchable URL for the media. Memoized so repeated reads don't
78
+ # re-issue the platform lookup (WhatsApp get_media_url / Telegram getFile).
79
+ # Returns nil (rather than raising) if the lookup fails, matching #download.
80
+ def url
81
+ @url ||= case platform
82
+ when :whatsapp then client.get_media_url(id)
83
+ when :telegram then client.file_url(file_id)
84
+ else @data[:url]
85
+ end
86
+ rescue => e
87
+ FlowChat.logger.warn { "Media: url resolution failed: #{e.message}" }
88
+ nil
89
+ end
90
+
91
+ # Fetch the raw bytes of the media, or nil on failure (uniform across
92
+ # platforms). WhatsApp needs the client to attach auth headers; every other
93
+ # platform (Telegram, Intercom, HTTP) exposes a token-in-URL or public link,
94
+ # so we fetch the already-memoized #url directly — avoiding a second getFile
95
+ # round-trip on Telegram.
96
+ def download
97
+ case platform
98
+ when :whatsapp then client.download_media(id)
99
+ else fetch(url)
100
+ end
101
+ rescue => e
102
+ FlowChat.logger.warn { "Media: download failed: #{e.message}" }
103
+ nil
104
+ end
105
+
106
+ private
107
+
108
+ def fetch(resource_url)
109
+ return nil unless resource_url
110
+
111
+ uri = URI(resource_url)
112
+ http = Net::HTTP.new(uri.host, uri.port)
113
+ http.use_ssl = (uri.scheme == "https")
114
+ response = http.get(uri.request_uri)
115
+ response.body if response.is_a?(Net::HTTPSuccess)
116
+ rescue => e
117
+ FlowChat.logger.warn { "Media: download failed for #{resource_url}: #{e.message}" }
118
+ nil
119
+ end
120
+ end
121
+ end