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.
- checksums.yaml +4 -4
- data/.cliff.toml +74 -0
- data/.github/workflows/ci.yml +2 -3
- data/.github/workflows/pages.yml +43 -0
- data/.github/workflows/release.yml +56 -0
- data/.standard.yml +4 -0
- data/CHANGELOG.md +48 -0
- data/CLAUDE.md +327 -0
- data/CONTRIBUTING.md +134 -0
- data/Gemfile +1 -0
- data/README.md +189 -133
- data/Rakefile +17 -2
- data/SECURITY.md +42 -349
- data/docs/architecture.md +83 -0
- data/docs/async-background-processing.md +64 -0
- data/docs/configuration.md +110 -287
- data/docs/factory-pattern.md +58 -0
- data/docs/gateway-context-variables.md +168 -0
- data/docs/gateway-development.md +159 -0
- data/docs/getting-started.md +90 -0
- data/docs/instrumentation.md +95 -175
- data/docs/platforms/instagram.md +278 -0
- data/docs/platforms/messenger.md +205 -0
- data/docs/platforms/telegram.md +109 -0
- data/docs/platforms/ussd.md +78 -0
- data/docs/platforms/whatsapp.md +147 -0
- data/docs/superpowers/plans/2026-07-09-inbound-media-support.md +732 -0
- data/docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json +58 -0
- data/docs/superpowers/plans/2026-08-10-messenger-instagram.md +4064 -0
- data/docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json +226 -0
- data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md +972 -0
- data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json +88 -0
- data/docs/superpowers/specs/2026-07-09-inbound-media-support-design.md +195 -0
- data/docs/superpowers/specs/2026-08-10-messenger-instagram-design.md +391 -0
- data/docs/testing.md +33 -426
- data/examples/custom_session_id_example.rb +119 -0
- data/examples/http_controller.rb +22 -20
- data/examples/intercom_configuration_example.rb +113 -0
- data/examples/intercom_controller.rb +182 -0
- data/examples/multi_tenant_whatsapp_controller.rb +63 -168
- data/examples/simulator_controller.rb +0 -1
- data/examples/ussd_controller.rb +88 -160
- data/examples/whatsapp_controller.rb +18 -17
- data/examples/whatsapp_media_examples.rb +27 -79
- data/flow_chat.gemspec +4 -0
- data/lib/flow_chat/app.rb +211 -0
- data/lib/flow_chat/async_job.rb +176 -0
- data/lib/flow_chat/choice_titles.rb +95 -0
- data/lib/flow_chat/config.rb +126 -23
- data/lib/flow_chat/delivery_error.rb +9 -0
- data/lib/flow_chat/{base_executor.rb → executor.rb} +6 -11
- data/lib/flow_chat/factory.rb +94 -0
- data/lib/flow_chat/gateway_async_support.rb +106 -0
- data/lib/flow_chat/generic_async_job.rb +30 -0
- data/lib/flow_chat/http/configuration_error.rb +9 -0
- data/lib/flow_chat/http/gateway/simple.rb +104 -36
- data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
- data/lib/flow_chat/http/renderer.rb +3 -3
- data/lib/flow_chat/input.rb +86 -0
- data/lib/flow_chat/instagram/client.rb +32 -0
- data/lib/flow_chat/instagram/configuration.rb +147 -0
- data/lib/flow_chat/instagram/configuration_error.rb +7 -0
- data/lib/flow_chat/instagram/gateway/send_api.rb +63 -0
- data/lib/flow_chat/instagram/middleware/choice_mapper.rb +22 -0
- data/lib/flow_chat/instagram/renderer.rb +23 -0
- data/lib/flow_chat/instrumentation/metrics_collector.rb +6 -1
- data/lib/flow_chat/instrumentation/setup.rb +1 -1
- data/lib/flow_chat/instrumentation.rb +182 -0
- data/lib/flow_chat/intercom/client.rb +161 -0
- data/lib/flow_chat/intercom/configuration.rb +102 -0
- data/lib/flow_chat/intercom/configuration_error.rb +9 -0
- data/lib/flow_chat/intercom/gateway/intercom_api.rb +420 -0
- data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
- data/lib/flow_chat/intercom/renderer.rb +123 -0
- data/lib/flow_chat/media.rb +121 -0
- data/lib/flow_chat/messenger/client.rb +264 -0
- data/lib/flow_chat/messenger/configuration.rb +103 -0
- data/lib/flow_chat/messenger/configuration_error.rb +9 -0
- data/lib/flow_chat/messenger/gateway/send_api.rb +42 -0
- data/lib/flow_chat/messenger/middleware/choice_mapper.rb +185 -0
- data/lib/flow_chat/messenger/renderer.rb +150 -0
- data/lib/flow_chat/meta/challenge.rb +24 -0
- data/lib/flow_chat/meta/choice_ladder.rb +37 -0
- data/lib/flow_chat/meta/configuration_error.rb +7 -0
- data/lib/flow_chat/meta/gateway_identity.rb +38 -0
- data/lib/flow_chat/meta/messaging_gateway.rb +468 -0
- data/lib/flow_chat/meta/signature.rb +30 -0
- data/lib/flow_chat/meta/signature_validation.rb +66 -0
- data/lib/flow_chat/meta/webhook_verification.rb +43 -0
- data/lib/flow_chat/named_configuration.rb +65 -0
- data/lib/flow_chat/phone_number_util.rb +37 -35
- data/lib/flow_chat/processor.rb +188 -0
- data/lib/flow_chat/prompt.rb +13 -16
- data/lib/flow_chat/renderers/markdown_support.rb +167 -0
- data/lib/flow_chat/security.rb +76 -0
- data/lib/flow_chat/session/middleware.rb +36 -11
- data/lib/flow_chat/simulator/controller.rb +31 -15
- data/lib/flow_chat/simulator/views/simulator.html.erb +184 -20
- data/lib/flow_chat/telegram/client.rb +283 -0
- data/lib/flow_chat/telegram/configuration.rb +78 -0
- data/lib/flow_chat/telegram/configuration_error.rb +9 -0
- data/lib/flow_chat/telegram/gateway/bot_api.rb +318 -0
- data/lib/flow_chat/telegram/middleware/choice_mapper.rb +96 -0
- data/lib/flow_chat/telegram/renderer.rb +133 -0
- data/lib/flow_chat/telegram.rb +7 -0
- data/lib/flow_chat/text_truncator.rb +75 -0
- data/lib/flow_chat/ussd/gateway/nalo.rb +24 -4
- data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
- data/lib/flow_chat/ussd/middleware/pagination.rb +9 -5
- data/lib/flow_chat/ussd/renderer.rb +1 -1
- data/lib/flow_chat/version.rb +1 -1
- data/lib/flow_chat/whatsapp/client.rb +158 -20
- data/lib/flow_chat/whatsapp/configuration.rb +13 -52
- data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
- data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +335 -248
- data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +234 -0
- data/lib/flow_chat/whatsapp/renderer.rb +259 -64
- data/lib/flow_chat.rb +1 -1
- data/lib/tasks/release.rake +165 -0
- data/site/.nojekyll +0 -0
- data/site/.og-card.html +89 -0
- data/site/favicon.svg +6 -0
- data/site/index.html +209 -0
- data/site/og.png +0 -0
- metadata +132 -25
- data/docs/flows.md +0 -320
- data/docs/http-gateway-protocol.md +0 -432
- data/docs/images/simulator.png +0 -0
- data/docs/media.md +0 -153
- data/docs/sessions.md +0 -433
- data/docs/ussd-setup.md +0 -322
- data/docs/whatsapp-setup.md +0 -162
- data/examples/whatsapp_message_job.rb +0 -113
- data/lib/flow_chat/base_app.rb +0 -86
- data/lib/flow_chat/base_processor.rb +0 -146
- data/lib/flow_chat/http/app.rb +0 -6
- data/lib/flow_chat/http/middleware/executor.rb +0 -24
- data/lib/flow_chat/http/processor.rb +0 -33
- data/lib/flow_chat/session/rails_session_store.rb +0 -68
- data/lib/flow_chat/ussd/app.rb +0 -6
- data/lib/flow_chat/ussd/gateway/nsano.rb +0 -96
- data/lib/flow_chat/ussd/middleware/executor.rb +0 -24
- data/lib/flow_chat/ussd/processor.rb +0 -39
- data/lib/flow_chat/whatsapp/app.rb +0 -29
- data/lib/flow_chat/whatsapp/middleware/executor.rb +0 -24
- data/lib/flow_chat/whatsapp/processor.rb +0 -32
- 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
|
-
|
|
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)
|
|
@@ -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
|