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
@@ -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|
@@ -2,7 +2,7 @@ module FlowChat
2
2
  module Instrumentation
3
3
  module Setup
4
4
  class << self
5
- attr_accessor :log_subscriber, :metrics_collector
5
+ attr_accessor :log_subscriber
6
6
 
7
7
  # Initialize instrumentation with default subscribers
8
8
  def initialize!
@@ -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)
@@ -35,6 +165,36 @@ module FlowChat
35
165
  ActiveSupport::Notifications.instrument(full_event_name, enriched_payload, &block)
36
166
  end
37
167
 
168
+ # Shared helper for reporting API errors with instrumentation and Rails.error
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
179
+ # @param error [Exception, nil] Original exception if available
180
+ # @param context [Hash] Platform-specific error context (must include :platform)
181
+ def self.report_api_error(message, error: nil, **context)
182
+ error_context = context.compact
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
+
188
+ # Instrument for custom subscribers
189
+ instrument(Events::API_ERROR, error_context.merge(message: message))
190
+
191
+ # Report to Rails.error if available
192
+ if defined?(Rails) && Rails.respond_to?(:error) && Rails.error.respond_to?(:report)
193
+ exception = error || StandardError.new(message)
194
+ Rails.error.report(exception, handled: true, context: error_context)
195
+ end
196
+ end
197
+
38
198
  # Predefined event names for consistency
39
199
  module Events
40
200
  # Core framework events
@@ -57,16 +217,38 @@ module FlowChat
57
217
  # Gateway/platform information is included in the payload
58
218
  MESSAGE_RECEIVED = "message.received"
59
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"
60
227
  WEBHOOK_VERIFIED = "webhook.verified"
61
228
  WEBHOOK_FAILED = "webhook.failed"
62
229
  API_REQUEST = "api.request"
63
230
  MEDIA_UPLOAD = "media.upload"
231
+ API_ERROR = "api.error"
64
232
 
65
233
  PAGINATION_TRIGGERED = "pagination.triggered"
66
234
 
67
235
  # Middleware events
68
236
  MIDDLEWARE_BEFORE = "middleware.before"
69
237
  MIDDLEWARE_AFTER = "middleware.after"
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
+
248
+ # Conversation management events (for Intercom and similar platforms)
249
+ CONVERSATION_ASSIGNED = "conversation.assigned"
250
+ CONVERSATION_TAGGED = "conversation.tagged"
251
+ CONVERSATION_STATE_CHANGED = "conversation.state_changed"
70
252
  end
71
253
  end
72
254
  end
@@ -0,0 +1,161 @@
1
+ require "intercom"
2
+ require "json"
3
+ require "reverse_markdown"
4
+
5
+ module FlowChat
6
+ module Intercom
7
+ # Rate limiting error
8
+ class RateLimitError < StandardError
9
+ attr_reader :retry_after
10
+
11
+ def initialize(message, retry_after = nil)
12
+ super(message)
13
+ @retry_after = retry_after
14
+ end
15
+ end
16
+
17
+ class Client
18
+ include FlowChat::Instrumentation
19
+
20
+ attr_reader :intercom
21
+ attr_accessor :app_id
22
+
23
+ # Convert HTML from Intercom messages to Markdown
24
+ def self.parse_html(html)
25
+ ReverseMarkdown.convert(html.to_s).strip.presence || ""
26
+ end
27
+
28
+ def parse_message(html)
29
+ self.class.parse_html(html)
30
+ end
31
+
32
+ def initialize(config)
33
+ @config = config
34
+ @intercom = ::Intercom::Client.new(token: @config.access_token)
35
+ FlowChat.logger.info { "Intercom::Client: Initialized Intercom client" }
36
+ FlowChat.logger.debug { "Intercom::Client: API base URL: #{@config.api_base_url}" }
37
+ end
38
+
39
+ # Send a reply to a conversation
40
+ # @param conversation_id [String] Conversation ID
41
+ # @param response [Array] FlowChat response array [type, content, options]
42
+ # @return [Hash] API response or nil on error
43
+ def send_message(conversation_id, prompt, choices: nil, media: nil)
44
+ FlowChat.logger.info { "Intercom::Client: Sending message to conversation #{conversation_id}" }
45
+ FlowChat.logger.debug { "Intercom::Client: Message content: '#{prompt.to_s.truncate(100)}'" }
46
+
47
+ # Use renderer to convert to structured response
48
+ response = FlowChat::Intercom::Renderer.new(prompt, choices: choices, media: media).render
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
59
+ # Determine message type based on response type
60
+ message_type = case type
61
+ when :note
62
+ "note"
63
+ else
64
+ "comment"
65
+ end
66
+
67
+ reply_data = {
68
+ id: conversation_id,
69
+ type: "admin",
70
+ admin_id: @config.admin_id.to_s,
71
+ message_type: message_type,
72
+ body: content.to_s
73
+ }
74
+ reply_data[:attachment_urls] = attachment_urls if attachment_urls
75
+
76
+ # Send using official gem
77
+ reply = intercom.conversations.reply(reply_data)
78
+
79
+ reply.to_hash
80
+ end
81
+
82
+ if result
83
+ message_id = result["id"]
84
+ FlowChat.logger.debug { "Intercom::Client: Message sent successfully to conversation #{conversation_id}, message_id: #{message_id}" }
85
+ else
86
+ FlowChat.logger.error { "Intercom::Client: Failed to send message to conversation #{conversation_id}" }
87
+ end
88
+
89
+ result
90
+ rescue ::Intercom::ResourceNotFound => e
91
+ FlowChat.logger.error { "Intercom::Client: Conversation not found: #{e.message}" }
92
+ report_api_error("Intercom conversation not found", error: e, error_type: "resource_not_found",
93
+ conversation_id: conversation_id)
94
+ nil
95
+ rescue ::Intercom::AuthenticationError => e
96
+ FlowChat.logger.error { "Intercom::Client: Authentication failed - check access token" }
97
+ report_api_error("Intercom authentication failed", error: e, error_type: "authentication",
98
+ conversation_id: conversation_id)
99
+ raise ConfigurationError, "Invalid Intercom access token"
100
+ rescue ::Intercom::RateLimitExceeded
101
+ retry_after = 60
102
+ FlowChat.logger.warn { "Intercom::Client: Rate limit exceeded - retry after #{retry_after}s" }
103
+ raise RateLimitError.new("Intercom API rate limit exceeded", retry_after)
104
+ rescue ::Intercom::ServerError => e
105
+ FlowChat.logger.error { "Intercom::Client: Server error: #{e.message}" }
106
+ report_api_error("Intercom server error", error: e, error_type: "server_error",
107
+ conversation_id: conversation_id)
108
+ nil
109
+ rescue => e
110
+ FlowChat.logger.error { "Intercom::Client: API request exception: #{e.class.name}: #{e.message}" }
111
+ report_api_error("Intercom API request exception: #{e.class.name}", error: e, conversation_id: conversation_id)
112
+ nil
113
+ end
114
+
115
+ # Build reply payload for Intercom API
116
+ # This method is exposed so the gateway can use it for simulator mode
117
+ def build_reply_payload(response, conversation_id)
118
+ type, content, options = response
119
+
120
+ payload = case type
121
+ when :note
122
+ {
123
+ message_type: "note",
124
+ type: "admin",
125
+ admin_id: @config.admin_id.to_s,
126
+ body: content.to_s
127
+ }
128
+ else
129
+ # :text and anything else default to comment
130
+ {
131
+ message_type: "comment",
132
+ type: "admin",
133
+ admin_id: @config.admin_id.to_s,
134
+ body: content.to_s
135
+ }
136
+ end
137
+
138
+ payload[:attachment_urls] = options[:attachment_urls] if options[:attachment_urls]
139
+ payload
140
+ end
141
+
142
+ private
143
+
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)
148
+ FlowChat::Instrumentation.report_api_error(
149
+ message,
150
+ error: error,
151
+ platform: :intercom,
152
+ app_id: @app_id,
153
+ error_type: error_type,
154
+ error_code: (error.http_code if error.is_a?(::Intercom::IntercomError)),
155
+ conversation_id: conversation_id,
156
+ admin_id: @config.admin_id
157
+ )
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,102 @@
1
+ module FlowChat
2
+ module Intercom
3
+ class Configuration
4
+ include FlowChat::NamedConfiguration
5
+
6
+ attr_accessor :access_token, :client_secret, :admin_id, :name, :skip_signature_validation
7
+
8
+ def initialize(name)
9
+ @name = name
10
+ @access_token = nil
11
+ @client_secret = nil
12
+ @admin_id = nil
13
+ @skip_signature_validation = false
14
+
15
+ FlowChat.logger.debug { "Intercom::Configuration: Initialized configuration with name: #{name || "anonymous"}" }
16
+
17
+ register_as(name) if name.present?
18
+ end
19
+
20
+ # Load configuration from Rails credentials or environment variables
21
+ def self.from_credentials
22
+ FlowChat.logger.info { "Intercom::Configuration: Loading configuration from credentials/environment" }
23
+
24
+ config = new(nil)
25
+
26
+ if defined?(Rails) && Rails.application.credentials.intercom
27
+ FlowChat.logger.debug { "Intercom::Configuration: Loading from Rails credentials" }
28
+ credentials = Rails.application.credentials.intercom
29
+ config.access_token = credentials[:access_token]
30
+ config.client_secret = credentials[:client_secret]
31
+ config.admin_id = credentials[:admin_id]
32
+ config.skip_signature_validation = credentials[:skip_signature_validation] || false
33
+ else
34
+ FlowChat.logger.debug { "Intercom::Configuration: Loading from environment variables" }
35
+ # Fallback to environment variables
36
+ config.access_token = ENV["INTERCOM_ACCESS_TOKEN"]
37
+ config.client_secret = ENV["INTERCOM_CLIENT_SECRET"]
38
+ config.admin_id = ENV["INTERCOM_ADMIN_ID"]
39
+ config.skip_signature_validation = ENV["INTERCOM_SKIP_SIGNATURE_VALIDATION"] == "true"
40
+ end
41
+
42
+ if config.valid?
43
+ FlowChat.logger.info { "Intercom::Configuration: Configuration loaded successfully" }
44
+ else
45
+ FlowChat.logger.warn { "Intercom::Configuration: Incomplete configuration loaded - missing required fields" }
46
+ end
47
+
48
+ config
49
+ end
50
+
51
+ def valid?
52
+ is_valid = !!(access_token && !access_token.to_s.empty? && admin_id && !admin_id.to_s.empty?)
53
+
54
+ FlowChat.logger.debug { "Intercom::Configuration: Configuration valid: #{is_valid}" }
55
+ is_valid
56
+ end
57
+
58
+ # API endpoints
59
+ def api_base_url
60
+ "https://api.intercom.io"
61
+ end
62
+
63
+ def conversations_url(conversation_id = nil)
64
+ if conversation_id
65
+ "#{api_base_url}/conversations/#{conversation_id}"
66
+ else
67
+ "#{api_base_url}/conversations"
68
+ end
69
+ end
70
+
71
+ def conversation_reply_url(conversation_id)
72
+ "#{conversations_url(conversation_id)}/reply"
73
+ end
74
+
75
+ def conversation_parts_url(conversation_id)
76
+ "#{conversations_url(conversation_id)}/parts"
77
+ end
78
+
79
+ def conversation_tags_url(conversation_id, tag_id = nil)
80
+ if tag_id
81
+ "#{conversations_url(conversation_id)}/tags/#{tag_id}"
82
+ else
83
+ "#{conversations_url(conversation_id)}/tags"
84
+ end
85
+ end
86
+
87
+ def admins_url
88
+ "#{api_base_url}/admins"
89
+ end
90
+
91
+ # Headers for API requests
92
+ def api_headers
93
+ {
94
+ "Authorization" => "Bearer #{access_token}",
95
+ "Content-Type" => "application/json",
96
+ "Accept" => "application/json",
97
+ "Intercom-Version" => "2.11"
98
+ }
99
+ end
100
+ end
101
+ end
102
+ end
@@ -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