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.
- checksums.yaml +4 -4
- data/.github/workflows/pages.yml +43 -0
- data/CHANGELOG.md +26 -0
- data/README.md +166 -295
- data/Rakefile +12 -1
- data/SECURITY.md +1 -1
- data/docs/architecture.md +52 -479
- data/docs/async-background-processing.md +31 -265
- data/docs/configuration.md +106 -613
- data/docs/factory-pattern.md +27 -324
- data/docs/gateway-context-variables.md +140 -143
- data/docs/gateway-development.md +86 -650
- data/docs/getting-started.md +40 -379
- data/docs/instrumentation.md +88 -279
- data/docs/platforms/instagram.md +278 -0
- data/docs/platforms/messenger.md +205 -0
- data/docs/platforms/telegram.md +47 -951
- data/docs/platforms/ussd.md +38 -653
- data/docs/platforms/whatsapp.md +73 -1321
- 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 +26 -297
- data/examples/http_controller.rb +12 -10
- data/examples/intercom_configuration_example.rb +19 -24
- data/examples/intercom_controller.rb +8 -20
- data/examples/multi_tenant_whatsapp_controller.rb +61 -166
- data/examples/simulator_controller.rb +0 -1
- data/examples/ussd_controller.rb +86 -158
- data/examples/whatsapp_controller.rb +16 -15
- data/examples/whatsapp_media_examples.rb +27 -79
- data/lib/flow_chat/app.rb +129 -11
- data/lib/flow_chat/choice_titles.rb +95 -0
- data/lib/flow_chat/config.rb +124 -1
- data/lib/flow_chat/delivery_error.rb +9 -0
- data/lib/flow_chat/http/configuration_error.rb +9 -0
- data/lib/flow_chat/http/gateway/simple.rb +24 -4
- data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
- 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.rb +160 -1
- data/lib/flow_chat/intercom/client.rb +34 -28
- data/lib/flow_chat/intercom/configuration.rb +2 -49
- data/lib/flow_chat/intercom/configuration_error.rb +9 -0
- data/lib/flow_chat/intercom/gateway/intercom_api.rb +81 -57
- data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
- data/lib/flow_chat/intercom/renderer.rb +57 -5
- 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/prompt.rb +13 -16
- data/lib/flow_chat/renderers/markdown_support.rb +109 -0
- data/lib/flow_chat/security.rb +76 -0
- data/lib/flow_chat/session/middleware.rb +11 -2
- 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 +47 -4
- data/lib/flow_chat/telegram/configuration.rb +2 -42
- data/lib/flow_chat/telegram/configuration_error.rb +9 -0
- data/lib/flow_chat/telegram/gateway/bot_api.rb +46 -28
- data/lib/flow_chat/telegram/middleware/choice_mapper.rb +77 -16
- data/lib/flow_chat/telegram/renderer.rb +10 -2
- data/lib/flow_chat/text_truncator.rb +75 -0
- data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
- data/lib/flow_chat/version.rb +1 -1
- data/lib/flow_chat/whatsapp/client.rb +14 -7
- data/lib/flow_chat/whatsapp/configuration.rb +12 -51
- data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
- data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +254 -203
- data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +144 -57
- data/lib/flow_chat/whatsapp/renderer.rb +121 -60
- data/lib/flow_chat.rb +0 -10
- 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 +51 -3
- 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
|
-
|
|
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
|
-
#
|
|
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,
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
4
|
+
include FlowChat::NamedConfiguration
|
|
5
5
|
|
|
6
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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 =
|
|
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
|
|
352
|
-
@body.dig("simulator_mode") &&
|
|
353
|
-
|
|
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)
|