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,63 @@
1
+ module FlowChat
2
+ module Instagram
3
+ module Gateway
4
+ # Instagram DMs, on the shared Messenger Platform envelope.
5
+ #
6
+ # A sibling of the Messenger gateway rather than a subclass of it: the
7
+ # two differ in credentials, limits and subscription object, and
8
+ # neither owns the other.
9
+ class SendApi < FlowChat::Meta::MessagingGateway
10
+ def platform
11
+ :instagram
12
+ end
13
+
14
+ def gateway_name
15
+ :instagram_send_api
16
+ end
17
+
18
+ def configuration_class
19
+ FlowChat::Instagram::Configuration
20
+ end
21
+
22
+ def client_class
23
+ FlowChat::Instagram::Client
24
+ end
25
+
26
+ def renderer_class
27
+ FlowChat::Instagram::Renderer
28
+ end
29
+
30
+ def self.choice_mapper_class
31
+ FlowChat::Instagram::Middleware::ChoiceMapper
32
+ end
33
+
34
+ # Confirmed against a live delivery for a page-linked account: Meta's
35
+ # docs are ambiguous about whether these arrive under "page" or
36
+ # "instagram", and they arrive under "instagram". The delivery named
37
+ # the Instagram professional account in entry.id, not the linked Page,
38
+ # which is what webhook_account_id encodes.
39
+ FACEBOOK_LOGIN_WEBHOOK_OBJECT = "instagram"
40
+
41
+ # Kept as its own constant rather than sharing one with the path above:
42
+ # the two integrations are configured independently in Meta's
43
+ # dashboard, so a correction to one path's value must not silently
44
+ # change the other's.
45
+ INSTAGRAM_LOGIN_WEBHOOK_OBJECT = "instagram"
46
+
47
+ def expected_webhook_object
48
+ (@config.login == :instagram) ? INSTAGRAM_LOGIN_WEBHOOK_OBJECT : FACEBOOK_LOGIN_WEBHOOK_OBJECT
49
+ end
50
+
51
+ private
52
+
53
+ def configuration_error_class
54
+ FlowChat::Instagram::ConfigurationError
55
+ end
56
+
57
+ def platform_label
58
+ "Instagram"
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,22 @@
1
+ module FlowChat
2
+ module Instagram
3
+ module Middleware
4
+ class ChoiceMapper < FlowChat::Messenger::Middleware::ChoiceMapper
5
+ ID_KEY = "instagram.choice_mapping"
6
+ POSITION_KEY = "instagram.position_mapping"
7
+
8
+ private
9
+
10
+ def platform_limits
11
+ FlowChat::Config.instagram
12
+ end
13
+
14
+ # The body always carries numbers here, so a typed number must
15
+ # always resolve, not only above the carousel capacity.
16
+ def always_number?
17
+ true
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -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