flow_chat 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +43 -0
  3. data/CHANGELOG.md +26 -0
  4. data/README.md +166 -295
  5. data/Rakefile +12 -1
  6. data/SECURITY.md +1 -1
  7. data/docs/architecture.md +52 -479
  8. data/docs/async-background-processing.md +31 -265
  9. data/docs/configuration.md +106 -613
  10. data/docs/factory-pattern.md +27 -324
  11. data/docs/gateway-context-variables.md +140 -143
  12. data/docs/gateway-development.md +86 -650
  13. data/docs/getting-started.md +40 -379
  14. data/docs/instrumentation.md +88 -279
  15. data/docs/platforms/instagram.md +278 -0
  16. data/docs/platforms/messenger.md +205 -0
  17. data/docs/platforms/telegram.md +47 -951
  18. data/docs/platforms/ussd.md +38 -653
  19. data/docs/platforms/whatsapp.md +73 -1321
  20. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md +732 -0
  21. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json +58 -0
  22. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md +4064 -0
  23. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json +226 -0
  24. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md +972 -0
  25. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json +88 -0
  26. data/docs/superpowers/specs/2026-07-09-inbound-media-support-design.md +195 -0
  27. data/docs/superpowers/specs/2026-08-10-messenger-instagram-design.md +391 -0
  28. data/docs/testing.md +26 -297
  29. data/examples/http_controller.rb +12 -10
  30. data/examples/intercom_configuration_example.rb +19 -24
  31. data/examples/intercom_controller.rb +8 -20
  32. data/examples/multi_tenant_whatsapp_controller.rb +61 -166
  33. data/examples/simulator_controller.rb +0 -1
  34. data/examples/ussd_controller.rb +86 -158
  35. data/examples/whatsapp_controller.rb +16 -15
  36. data/examples/whatsapp_media_examples.rb +27 -79
  37. data/lib/flow_chat/app.rb +129 -11
  38. data/lib/flow_chat/choice_titles.rb +95 -0
  39. data/lib/flow_chat/config.rb +124 -1
  40. data/lib/flow_chat/delivery_error.rb +9 -0
  41. data/lib/flow_chat/http/configuration_error.rb +9 -0
  42. data/lib/flow_chat/http/gateway/simple.rb +24 -4
  43. data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
  44. data/lib/flow_chat/input.rb +86 -0
  45. data/lib/flow_chat/instagram/client.rb +32 -0
  46. data/lib/flow_chat/instagram/configuration.rb +147 -0
  47. data/lib/flow_chat/instagram/configuration_error.rb +7 -0
  48. data/lib/flow_chat/instagram/gateway/send_api.rb +63 -0
  49. data/lib/flow_chat/instagram/middleware/choice_mapper.rb +22 -0
  50. data/lib/flow_chat/instagram/renderer.rb +23 -0
  51. data/lib/flow_chat/instrumentation/metrics_collector.rb +6 -1
  52. data/lib/flow_chat/instrumentation.rb +160 -1
  53. data/lib/flow_chat/intercom/client.rb +34 -28
  54. data/lib/flow_chat/intercom/configuration.rb +2 -49
  55. data/lib/flow_chat/intercom/configuration_error.rb +9 -0
  56. data/lib/flow_chat/intercom/gateway/intercom_api.rb +81 -57
  57. data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
  58. data/lib/flow_chat/intercom/renderer.rb +57 -5
  59. data/lib/flow_chat/media.rb +121 -0
  60. data/lib/flow_chat/messenger/client.rb +264 -0
  61. data/lib/flow_chat/messenger/configuration.rb +103 -0
  62. data/lib/flow_chat/messenger/configuration_error.rb +9 -0
  63. data/lib/flow_chat/messenger/gateway/send_api.rb +42 -0
  64. data/lib/flow_chat/messenger/middleware/choice_mapper.rb +185 -0
  65. data/lib/flow_chat/messenger/renderer.rb +150 -0
  66. data/lib/flow_chat/meta/challenge.rb +24 -0
  67. data/lib/flow_chat/meta/choice_ladder.rb +37 -0
  68. data/lib/flow_chat/meta/configuration_error.rb +7 -0
  69. data/lib/flow_chat/meta/gateway_identity.rb +38 -0
  70. data/lib/flow_chat/meta/messaging_gateway.rb +468 -0
  71. data/lib/flow_chat/meta/signature.rb +30 -0
  72. data/lib/flow_chat/meta/signature_validation.rb +66 -0
  73. data/lib/flow_chat/meta/webhook_verification.rb +43 -0
  74. data/lib/flow_chat/named_configuration.rb +65 -0
  75. data/lib/flow_chat/prompt.rb +13 -16
  76. data/lib/flow_chat/renderers/markdown_support.rb +109 -0
  77. data/lib/flow_chat/security.rb +76 -0
  78. data/lib/flow_chat/session/middleware.rb +11 -2
  79. data/lib/flow_chat/simulator/controller.rb +31 -15
  80. data/lib/flow_chat/simulator/views/simulator.html.erb +184 -20
  81. data/lib/flow_chat/telegram/client.rb +47 -4
  82. data/lib/flow_chat/telegram/configuration.rb +2 -42
  83. data/lib/flow_chat/telegram/configuration_error.rb +9 -0
  84. data/lib/flow_chat/telegram/gateway/bot_api.rb +46 -28
  85. data/lib/flow_chat/telegram/middleware/choice_mapper.rb +77 -16
  86. data/lib/flow_chat/telegram/renderer.rb +10 -2
  87. data/lib/flow_chat/text_truncator.rb +75 -0
  88. data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
  89. data/lib/flow_chat/version.rb +1 -1
  90. data/lib/flow_chat/whatsapp/client.rb +14 -7
  91. data/lib/flow_chat/whatsapp/configuration.rb +12 -51
  92. data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
  93. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +254 -203
  94. data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +144 -57
  95. data/lib/flow_chat/whatsapp/renderer.rb +121 -60
  96. data/lib/flow_chat.rb +0 -10
  97. data/site/.nojekyll +0 -0
  98. data/site/.og-card.html +89 -0
  99. data/site/favicon.svg +6 -0
  100. data/site/index.html +209 -0
  101. data/site/og.png +0 -0
  102. metadata +51 -3
  103. data/lib/flow_chat/whatsapp/id_generator.rb +0 -124
@@ -0,0 +1,23 @@
1
+ module FlowChat
2
+ module Instagram
3
+ # Instagram's ladder is Messenger's algorithm with different constants.
4
+ # The gateways are siblings (Instagram's does not depend on Messenger's),
5
+ # but the renderers really are the same shape, so this is the one place
6
+ # Instagram inherits from Messenger rather than mirroring it.
7
+ class Renderer < FlowChat::Messenger::Renderer
8
+ private
9
+
10
+ def limits
11
+ FlowChat::Config.instagram
12
+ end
13
+
14
+ # Quick replies and carousels are mobile only on Instagram, so the
15
+ # options are always listed in the body as well. A user on desktop
16
+ # sees the prompt and nothing tappable, and without the list has no
17
+ # way to reply at all.
18
+ def always_number?
19
+ true
20
+ end
21
+ end
22
+ end
23
+ end
@@ -82,7 +82,12 @@ module FlowChat
82
82
  if event.payload[:message_type]
83
83
  increment_counter("#{platform}.messages.sent.by_type.#{event.payload[:message_type]}")
84
84
  end
85
- track_timing("#{platform}.api.response_time", event.duration)
85
+ # The gateway measures the send itself and puts it on the payload.
86
+ # event.duration would read as zero here: MESSAGE_SENT is published
87
+ # after the send returns, rather than wrapped around it, because a
88
+ # wrapped event fires whatever the block returned - including for
89
+ # sends the platform refused.
90
+ track_timing("#{platform}.api.response_time", event.payload[:duration_ms] || event.duration)
86
91
  end
87
92
 
88
93
  ActiveSupport::Notifications.subscribe("webhook.verified.flow_chat") do |event|
@@ -4,6 +4,20 @@ module FlowChat
4
4
  module Instrumentation
5
5
  extend ActiveSupport::Concern
6
6
 
7
+ # Where a delivered reply's platform message id is left on the context, the
8
+ # same way for every gateway. nil when the platform does not name one.
9
+ DELIVERED_MESSAGE_ID_KEY = "delivery.platform_message_id"
10
+
11
+ # How long the send itself took, in milliseconds, left here by
12
+ # report_delivery_failure for the gateway to put on MESSAGE_SENT.
13
+ #
14
+ # Measured rather than taken from ActiveSupport::Notifications' own event
15
+ # duration: a block event is published whatever the block returns, so
16
+ # timing the send that way meant publishing MESSAGE_SENT for sends that
17
+ # failed. The event is emitted after the fact instead, which leaves its
18
+ # own duration at zero, so the real figure is carried in the payload.
19
+ DELIVERY_DURATION_KEY = "delivery.duration_ms"
20
+
7
21
  # Instrument a block of code with the given event name and payload
8
22
  def instrument(event_name, payload = {}, &block)
9
23
  enriched_payload = payload&.dup || {}
@@ -18,6 +32,122 @@ module FlowChat
18
32
  self.class.instrument(event_name, enriched_payload, &block)
19
33
  end
20
34
 
35
+ # Wraps a delivery so a reply the platform would not take is reported.
36
+ #
37
+ # A gateway sends after the middleware stack has returned. An app that
38
+ # records what the flow said has therefore already recorded it, and
39
+ # recorded it as having gone out, before anything knows whether it did.
40
+ # The send is the only place that learns otherwise, and it is downstream of
41
+ # everything that could act on it.
42
+ #
43
+ # Reported two ways, because two different kinds of reader want it.
44
+ #
45
+ # The event is a broadcast, and takes the same shape its gateway gives
46
+ # MESSAGE_SENT: what was being sent and where, and nothing else. Anyone may
47
+ # subscribe, including tools that write whatever they are handed straight
48
+ # into a log, so it carries no more than the send itself already announces.
49
+ #
50
+ # The callback is the app that owns this turn, acting on records only it
51
+ # knows about. It gets the whole context because it is the app's own code,
52
+ # configured by the app, and reading what the app put there. That is not
53
+ # true of a subscriber, and the context holds the gateway client and the
54
+ # raw inbound body.
55
+ #
56
+ # Re-raises whatever the send raised: this reports a failure, it does not
57
+ # handle one.
58
+ # A send fails two ways and only one of them raises. Every client here answers
59
+ # with the platform's parsed response when the message was accepted and nil
60
+ # once it has already logged an API error, so a nil result is a failure that
61
+ # arrived quietly. Treating it as success fired on_delivery_success for a
62
+ # message that was never delivered, and stamped a nil id onto the context as
63
+ # though the platform had named one.
64
+ #
65
+ # It reports rather than raises, because the client already decided not to:
66
+ # turning a swallowed API error into an exception here would fail the webhook
67
+ # for a reply the platform merely declined.
68
+ def report_delivery_failure(context, **payload)
69
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
70
+ result = yield
71
+ context[DELIVERY_DURATION_KEY] = elapsed_ms_since(started_at)
72
+
73
+ if result.nil?
74
+ error = FlowChat::DeliveryError.new("#{payload[:platform] || "the platform"} did not accept the message")
75
+ report_to_subscribers(error, payload)
76
+ report_to_app(context, error)
77
+ return nil
78
+ end
79
+
80
+ report_delivery_success(context, result)
81
+ result
82
+ rescue => error
83
+ context[DELIVERY_DURATION_KEY] ||= elapsed_ms_since(started_at) if started_at
84
+ report_to_subscribers(error, payload)
85
+ report_to_app(context, error)
86
+ raise error
87
+ end
88
+
89
+ def elapsed_ms_since(started_at)
90
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(2)
91
+ end
92
+
93
+ # The success half. Runs where the send happened, which is the only place that
94
+ # knows what the platform called the message.
95
+ def report_delivery_success(context, result)
96
+ context[DELIVERED_MESSAGE_ID_KEY] = platform_message_id_from(result)
97
+ FlowChat::Config.on_delivery_success&.call(context, result)
98
+ rescue => callback_error
99
+ FlowChat.logger.error do
100
+ "Instrumentation: on_delivery_success raised #{callback_error.class}: #{callback_error.message}"
101
+ end
102
+ end
103
+
104
+ # What the platform called the message it just accepted.
105
+ #
106
+ # Overridden by every gateway that delivers out of band, because each one is
107
+ # the only thing that knows the shape of its own client's answer. Naming it
108
+ # here rather than in each app is the point: an app stamping the id onto its
109
+ # own record should not have to carry a case statement over platforms.
110
+ def platform_message_id_from(result)
111
+ nil
112
+ end
113
+
114
+ # Neither reader may replace the delivery error with one of its own, which
115
+ # would hide the failure they are being told about. Notifications gather
116
+ # subscriber errors and re-raise them, so both are reachable.
117
+ def report_to_subscribers(error, payload)
118
+ instrument(Events::MESSAGE_DELIVERY_FAILED, payload.merge(
119
+ error_class: error.class.name,
120
+ message: error.message
121
+ ))
122
+ rescue => subscriber_error
123
+ FlowChat.logger.error do
124
+ "Instrumentation: a #{Events::MESSAGE_DELIVERY_FAILED} subscriber raised " \
125
+ "#{subscriber_error.class}: #{subscriber_error.message}"
126
+ end
127
+ end
128
+
129
+ def report_to_app(context, error)
130
+ FlowChat::Config.on_delivery_failure&.call(context, error)
131
+ rescue => callback_error
132
+ FlowChat.logger.error do
133
+ "Instrumentation: on_delivery_failure raised #{callback_error.class}: #{callback_error.message}"
134
+ end
135
+ end
136
+
137
+ # True when this turn carries something to process — text OR a structured
138
+ # attachment (media/location/contact). Gateways gate MESSAGE_RECEIVED on this
139
+ # so caption-less media, locations, and contacts are still instrumented: they
140
+ # set a blank input string (not the old "$media$"-style sentinel), so a plain
141
+ # `context.input.present?` check would silently drop them.
142
+ def inbound_message?(context)
143
+ return false unless context
144
+
145
+ context.input.present? ||
146
+ !context["request.media"].nil? ||
147
+ !context["request.location"].nil? ||
148
+ !context["request.contact"].nil?
149
+ end
150
+
21
151
  class_methods do
22
152
  def instrument(event_name, payload = {}, &block)
23
153
  FlowChat::Instrumentation.instrument(event_name, payload, &block)
@@ -36,12 +166,25 @@ module FlowChat
36
166
  end
37
167
 
38
168
  # Shared helper for reporting API errors with instrumentation and Rails.error
39
- # @param message [String] Error message
169
+ #
170
+ # `message` is prose for a human reading logs. Subscribers deciding what to
171
+ # do about an error should read the structured keys instead, so that
172
+ # rewording a message never changes behaviour somewhere else:
173
+ #
174
+ # error_class the exception's class, filled in here from `error`
175
+ # error_type what kind of failure it is, named by the adapter
176
+ # error_code the platform's own code, where it gives one
177
+ #
178
+ # @param message [String] Human readable description, for logs
40
179
  # @param error [Exception, nil] Original exception if available
41
180
  # @param context [Hash] Platform-specific error context (must include :platform)
42
181
  def self.report_api_error(message, error: nil, **context)
43
182
  error_context = context.compact
44
183
 
184
+ # An exception's class is a classification the caller already made. Carry
185
+ # it so a subscriber can branch on it rather than parsing the message.
186
+ error_context[:error_class] ||= error.class.name if error
187
+
45
188
  # Instrument for custom subscribers
46
189
  instrument(Events::API_ERROR, error_context.merge(message: message))
47
190
 
@@ -74,6 +217,13 @@ module FlowChat
74
217
  # Gateway/platform information is included in the payload
75
218
  MESSAGE_RECEIVED = "message.received"
76
219
  MESSAGE_SENT = "message.sent"
220
+ # A reply the flow produced that the platform would not take. Carries
221
+ # what its gateway gives MESSAGE_SENT, plus the error, so a subscriber
222
+ # sees the same send it would have seen succeed.
223
+ MESSAGE_DELIVERY_FAILED = "message.delivery_failed"
224
+ # A platform's own report of what became of a message we sent. Informational:
225
+ # the send already succeeded or failed at the API call.
226
+ MESSAGE_STATUS = "message.status"
77
227
  WEBHOOK_VERIFIED = "webhook.verified"
78
228
  WEBHOOK_FAILED = "webhook.failed"
79
229
  API_REQUEST = "api.request"
@@ -86,6 +236,15 @@ module FlowChat
86
236
  MIDDLEWARE_BEFORE = "middleware.before"
87
237
  MIDDLEWARE_AFTER = "middleware.after"
88
238
 
239
+ # A webhook this gateway verified but does not model, handed on whole.
240
+ #
241
+ # FlowChat's job is messaging: inbound turns, the replies they produce, and
242
+ # what became of them. A platform sends far more than that, and what an
243
+ # account ban, a contact sync or an imported history means belongs to the
244
+ # application, not here. Rather than grow a handler per field, the payload is
245
+ # published with the field that named it, for an application to dispatch on.
246
+ WEBHOOK_RECEIVED = "webhook.received"
247
+
89
248
  # Conversation management events (for Intercom and similar platforms)
90
249
  CONVERSATION_ASSIGNED = "conversation.assigned"
91
250
  CONVERSATION_TAGGED = "conversation.tagged"
@@ -4,9 +4,6 @@ require "reverse_markdown"
4
4
 
5
5
  module FlowChat
6
6
  module Intercom
7
- # Configuration-related errors
8
- class ConfigurationError < StandardError; end
9
-
10
7
  # Rate limiting error
11
8
  class RateLimitError < StandardError
12
9
  attr_reader :retry_after
@@ -49,14 +46,16 @@ module FlowChat
49
46
 
50
47
  # Use renderer to convert to structured response
51
48
  response = FlowChat::Intercom::Renderer.new(prompt, choices: choices, media: media).render
52
- type, content, _ = response
53
-
54
- result = instrument(Events::MESSAGE_SENT, {
55
- to: conversation_id,
56
- message_type: type.to_s,
57
- content_length: content.to_s.length,
58
- platform: :intercom
59
- }) do
49
+ type, content, options = response
50
+ attachment_urls = options[:attachment_urls]
51
+
52
+ # MESSAGE_SENT is instrumented by the gateway, not here. This wrapped
53
+ # the send in its own instrument block, and ActiveSupport::Notifications
54
+ # publishes a block event once the block returns whatever it returned -
55
+ # so the event fired even when the send had failed and this method was
56
+ # about to answer nil, and fired a second time when the gateway
57
+ # instrumented the same send.
58
+ result = begin
60
59
  # Determine message type based on response type
61
60
  message_type = case type
62
61
  when :note
@@ -65,14 +64,17 @@ module FlowChat
65
64
  "comment"
66
65
  end
67
66
 
68
- # Send using official gem
69
- reply = intercom.conversations.reply(
67
+ reply_data = {
70
68
  id: conversation_id,
71
69
  type: "admin",
72
70
  admin_id: @config.admin_id.to_s,
73
71
  message_type: message_type,
74
72
  body: content.to_s
75
- )
73
+ }
74
+ reply_data[:attachment_urls] = attachment_urls if attachment_urls
75
+
76
+ # Send using official gem
77
+ reply = intercom.conversations.reply(reply_data)
76
78
 
77
79
  reply.to_hash
78
80
  end
@@ -87,11 +89,13 @@ module FlowChat
87
89
  result
88
90
  rescue ::Intercom::ResourceNotFound => e
89
91
  FlowChat.logger.error { "Intercom::Client: Conversation not found: #{e.message}" }
90
- report_api_error("Intercom conversation not found", error: e, conversation_id: conversation_id)
92
+ report_api_error("Intercom conversation not found", error: e, error_type: "resource_not_found",
93
+ conversation_id: conversation_id)
91
94
  nil
92
95
  rescue ::Intercom::AuthenticationError => e
93
96
  FlowChat.logger.error { "Intercom::Client: Authentication failed - check access token" }
94
- report_api_error("Intercom authentication failed", error: e, conversation_id: conversation_id)
97
+ report_api_error("Intercom authentication failed", error: e, error_type: "authentication",
98
+ conversation_id: conversation_id)
95
99
  raise ConfigurationError, "Invalid Intercom access token"
96
100
  rescue ::Intercom::RateLimitExceeded
97
101
  retry_after = 60
@@ -99,7 +103,8 @@ module FlowChat
99
103
  raise RateLimitError.new("Intercom API rate limit exceeded", retry_after)
100
104
  rescue ::Intercom::ServerError => e
101
105
  FlowChat.logger.error { "Intercom::Client: Server error: #{e.message}" }
102
- report_api_error("Intercom server error", error: e, conversation_id: conversation_id)
106
+ report_api_error("Intercom server error", error: e, error_type: "server_error",
107
+ conversation_id: conversation_id)
103
108
  nil
104
109
  rescue => e
105
110
  FlowChat.logger.error { "Intercom::Client: API request exception: #{e.class.name}: #{e.message}" }
@@ -110,16 +115,9 @@ module FlowChat
110
115
  # Build reply payload for Intercom API
111
116
  # This method is exposed so the gateway can use it for simulator mode
112
117
  def build_reply_payload(response, conversation_id)
113
- type, content, _ = response
118
+ type, content, options = response
114
119
 
115
- case type
116
- when :text
117
- {
118
- message_type: "comment",
119
- type: "admin",
120
- admin_id: @config.admin_id.to_s,
121
- body: content.to_s
122
- }
120
+ payload = case type
123
121
  when :note
124
122
  {
125
123
  message_type: "note",
@@ -128,7 +126,7 @@ module FlowChat
128
126
  body: content.to_s
129
127
  }
130
128
  else
131
- # Default to comment
129
+ # :text and anything else default to comment
132
130
  {
133
131
  message_type: "comment",
134
132
  type: "admin",
@@ -136,16 +134,24 @@ module FlowChat
136
134
  body: content.to_s
137
135
  }
138
136
  end
137
+
138
+ payload[:attachment_urls] = options[:attachment_urls] if options[:attachment_urls]
139
+ payload
139
140
  end
140
141
 
141
142
  private
142
143
 
143
- def report_api_error(message, error: nil, conversation_id: nil)
144
+ # The Intercom gem raises a distinct class per failure and carries the
145
+ # HTTP status on it. Both say more than the message does, so pass them on
146
+ # rather than leaving a subscriber to read prose.
147
+ def report_api_error(message, error: nil, error_type: nil, conversation_id: nil)
144
148
  FlowChat::Instrumentation.report_api_error(
145
149
  message,
146
150
  error: error,
147
151
  platform: :intercom,
148
152
  app_id: @app_id,
153
+ error_type: error_type,
154
+ error_code: (error.http_code if error.is_a?(::Intercom::IntercomError)),
149
155
  conversation_id: conversation_id,
150
156
  admin_id: @config.admin_id
151
157
  )
@@ -1,10 +1,9 @@
1
1
  module FlowChat
2
2
  module Intercom
3
3
  class Configuration
4
- attr_accessor :access_token, :client_secret, :admin_id, :name, :skip_signature_validation
4
+ include FlowChat::NamedConfiguration
5
5
 
6
- # Class-level storage for named configurations
7
- @@configurations = {}
6
+ attr_accessor :access_token, :client_secret, :admin_id, :name, :skip_signature_validation
8
7
 
9
8
  def initialize(name)
10
9
  @name = name
@@ -49,52 +48,6 @@ module FlowChat
49
48
  config
50
49
  end
51
50
 
52
- # Register a named configuration
53
- def self.register(name, config)
54
- FlowChat.logger.debug { "Intercom::Configuration: Registering configuration '#{name}'" }
55
- @@configurations[name.to_sym] = config
56
- end
57
-
58
- # Get a named configuration
59
- def self.get(name)
60
- config = @@configurations[name.to_sym]
61
- if config
62
- FlowChat.logger.debug { "Intercom::Configuration: Retrieved configuration '#{name}'" }
63
- config
64
- else
65
- FlowChat.logger.error { "Intercom::Configuration: Configuration '#{name}' not found" }
66
- raise ArgumentError, "Intercom configuration '#{name}' not found"
67
- end
68
- end
69
-
70
- # Check if a named configuration exists
71
- def self.exists?(name)
72
- exists = @@configurations.key?(name.to_sym)
73
- FlowChat.logger.debug { "Intercom::Configuration: Configuration '#{name}' exists: #{exists}" }
74
- exists
75
- end
76
-
77
- # Get all configuration names
78
- def self.configuration_names
79
- names = @@configurations.keys
80
- FlowChat.logger.debug { "Intercom::Configuration: Available configurations: #{names}" }
81
- names
82
- end
83
-
84
- # Clear all registered configurations (useful for testing)
85
- def self.clear_all!
86
- FlowChat.logger.debug { "Intercom::Configuration: Clearing all registered configurations" }
87
- @@configurations.clear
88
- end
89
-
90
- # Register this configuration with a name
91
- def register_as(name)
92
- FlowChat.logger.debug { "Intercom::Configuration: Registering configuration as '#{name}'" }
93
- @name = name.to_sym
94
- self.class.register(@name, self)
95
- self
96
- end
97
-
98
51
  def valid?
99
52
  is_valid = !!(access_token && !access_token.to_s.empty? && admin_id && !admin_id.to_s.empty?)
100
53
 
@@ -0,0 +1,9 @@
1
+ module FlowChat
2
+ module Intercom
3
+ # Its own file so Zeitwerk can resolve it by name. Declared inside
4
+ # client.rb and gateway/intercom_api.rb, which Zeitwerk maps to a different constant, it existed only
5
+ # once that file had loaded for some other reason, so an application
6
+ # rescuing it, or a test naming it first, got an uninitialized constant.
7
+ class ConfigurationError < StandardError; end
8
+ end
9
+ end
@@ -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)