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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +29 -0
- data/.github/workflows/pages.yml +43 -0
- data/.gitignore +4 -1
- data/CHANGELOG.md +49 -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/flow_chat.gemspec +22 -2
- data/gemfiles/rails_main.gemfile +18 -0
- data/lib/flow_chat/app.rb +129 -11
- data/lib/flow_chat/async_job.rb +10 -0
- 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/gateway_async_support.rb +19 -1
- 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 +471 -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 +259 -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/lib/tasks/release.rake +11 -1
- 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 +60 -11
- 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
|
-
|
|
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
|