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
data/lib/flow_chat/config.rb
CHANGED
|
@@ -10,6 +10,37 @@ module FlowChat
|
|
|
10
10
|
# When true, inject logger into middleware stack. Defaults to true in Rails development.
|
|
11
11
|
mattr_accessor :inject_middleware_logger, default: defined?(Rails) && Rails.env.development?
|
|
12
12
|
|
|
13
|
+
# Called with the turn's context and the error when a gateway cannot
|
|
14
|
+
# deliver a reply the flow produced, before the error is re-raised.
|
|
15
|
+
#
|
|
16
|
+
# For the app that owns the turn, acting on records only it knows about: a
|
|
17
|
+
# gateway sends after the middleware stack has returned, so an app that
|
|
18
|
+
# recorded the reply has already recorded it as sent, and nothing
|
|
19
|
+
# downstream of the send can tell it otherwise.
|
|
20
|
+
#
|
|
21
|
+
# The whole context, because this is the app's own code reading what the
|
|
22
|
+
# app put there. Anything wanting only to watch deliveries fail should
|
|
23
|
+
# subscribe to message.delivery_failed instead, which carries no more than
|
|
24
|
+
# a successful send announces.
|
|
25
|
+
#
|
|
26
|
+
# Raising here would replace the delivery error with this one, so an
|
|
27
|
+
# exception is logged and dropped.
|
|
28
|
+
mattr_accessor :on_delivery_failure, default: nil
|
|
29
|
+
|
|
30
|
+
# Called with (context, result) once a reply has actually been delivered,
|
|
31
|
+
# where result is whatever the platform's client returned. The mirror of
|
|
32
|
+
# on_delivery_failure, and the only place an app can learn the id the platform
|
|
33
|
+
# gave a message: gateways deliver after the middleware stack has unwound, so
|
|
34
|
+
# a row written during the turn does not yet know it.
|
|
35
|
+
#
|
|
36
|
+
# Every gateway that delivers out of band names that id the same way, on the
|
|
37
|
+
# context as "delivery.platform_message_id", so an app does not have to know
|
|
38
|
+
# the shape of each platform's answer.
|
|
39
|
+
#
|
|
40
|
+
# Raising here would replace a successful send with an error, so an exception
|
|
41
|
+
# is logged and dropped.
|
|
42
|
+
mattr_accessor :on_delivery_success, default: nil
|
|
43
|
+
|
|
13
44
|
# Session configuration object
|
|
14
45
|
def self.session
|
|
15
46
|
@session ||= SessionConfig.new
|
|
@@ -30,6 +61,16 @@ module FlowChat
|
|
|
30
61
|
@http ||= HttpConfig.new
|
|
31
62
|
end
|
|
32
63
|
|
|
64
|
+
# Messenger-specific configuration object
|
|
65
|
+
def self.messenger
|
|
66
|
+
@messenger ||= MessengerConfig.new
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Instagram-specific configuration object
|
|
70
|
+
def self.instagram
|
|
71
|
+
@instagram ||= InstagramConfig.new
|
|
72
|
+
end
|
|
73
|
+
|
|
33
74
|
class SessionConfig
|
|
34
75
|
attr_accessor :boundaries, :hash_identifiers, :identifier, :session_id_proc
|
|
35
76
|
|
|
@@ -67,10 +108,92 @@ module FlowChat
|
|
|
67
108
|
end
|
|
68
109
|
|
|
69
110
|
class WhatsappConfig
|
|
70
|
-
|
|
111
|
+
# api_base_url is writable, unlike the limits beside it. Those are facts
|
|
112
|
+
# about the platform that an application cannot change by disagreeing. The
|
|
113
|
+
# version in the host is a choice, and one an application has to be able
|
|
114
|
+
# to make: Meta retires a version roughly every two years and pins the
|
|
115
|
+
# webhook payloads it sends to whatever the app's dashboard says, so an
|
|
116
|
+
# application straddling two versions must be able to close the gap
|
|
117
|
+
# without waiting for a release here.
|
|
118
|
+
attr_accessor :api_base_url
|
|
119
|
+
attr_reader :max_buttons, :max_list_rows
|
|
120
|
+
|
|
121
|
+
def initialize
|
|
122
|
+
@api_base_url = "https://graph.facebook.com/v23.0"
|
|
123
|
+
# Meta: "You cannot have more than 3 buttons in an interactive message."
|
|
124
|
+
@max_buttons = 3
|
|
125
|
+
# Meta: "up to 10 sections, with up to 10 rows for all sections combined".
|
|
126
|
+
@max_list_rows = 10
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Bridges max_buttons/max_list_rows to the shape
|
|
130
|
+
# FlowChat::Meta::ChoiceLadder expects, so the renderer and the choice
|
|
131
|
+
# mapper can both ask it which rung a count lands on instead of each
|
|
132
|
+
# re-deriving the same two-threshold comparison independently.
|
|
133
|
+
#
|
|
134
|
+
# WhatsApp's list has no further structure the way Messenger's
|
|
135
|
+
# carousel has elements and buttons per element - it is just a flat
|
|
136
|
+
# row cap - so it is modelled as a single element holding every row
|
|
137
|
+
# (max_buttons_per_element: 1) purely to fit ChoiceLadder's
|
|
138
|
+
# carousel_capacity formula (elements * buttons_per_element). That
|
|
139
|
+
# shape stays private to this adapter rather than becoming
|
|
140
|
+
# max_buttons/max_list_rows' own public meaning, since "carousel" and
|
|
141
|
+
# "buttons per element" describe nothing WhatsApp actually has.
|
|
142
|
+
def ladder_limits
|
|
143
|
+
LADDER_LIMITS_SHAPE.new(max_buttons, max_list_rows, 1)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
LADDER_LIMITS_SHAPE = Struct.new(:max_quick_replies, :max_carousel_elements, :max_buttons_per_element)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
class MessengerConfig
|
|
150
|
+
# Writable for the same reason as WhatsappConfig's, above.
|
|
151
|
+
attr_accessor :api_base_url
|
|
152
|
+
attr_reader :max_text_length, :max_quick_replies,
|
|
153
|
+
:max_quick_reply_title, :max_carousel_elements, :max_buttons_per_element,
|
|
154
|
+
:max_button_title, :max_element_title
|
|
155
|
+
|
|
156
|
+
def initialize
|
|
157
|
+
@api_base_url = "https://graph.facebook.com/v23.0"
|
|
158
|
+
# Meta does not state a text limit for Messenger on any current reference
|
|
159
|
+
# page, unlike Instagram's documented 1,000 bytes. 2000 is the long-cited
|
|
160
|
+
# figure and is safe to be wrong about in this direction: the client
|
|
161
|
+
# splits text at this value rather than truncating it, so a limit set too
|
|
162
|
+
# low sends an extra message and one set too high gets rejected. Raise it
|
|
163
|
+
# only against a documented figure.
|
|
164
|
+
@max_text_length = 2000
|
|
165
|
+
@max_quick_replies = 13
|
|
166
|
+
@max_quick_reply_title = 20
|
|
167
|
+
@max_carousel_elements = 10
|
|
168
|
+
@max_buttons_per_element = 3
|
|
169
|
+
@max_button_title = 20
|
|
170
|
+
@max_element_title = 80
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
class InstagramConfig
|
|
175
|
+
# Both hosts writable, and separately: the two integration paths are
|
|
176
|
+
# configured independently at Meta, so an application moving one to a new
|
|
177
|
+
# version has not necessarily moved the other.
|
|
178
|
+
attr_accessor :api_base_url, :instagram_login_api_base_url
|
|
179
|
+
attr_reader :max_text_length, :max_quick_replies,
|
|
180
|
+
:max_quick_reply_title, :max_carousel_elements, :max_buttons_per_element,
|
|
181
|
+
:max_button_title, :max_element_title
|
|
71
182
|
|
|
72
183
|
def initialize
|
|
184
|
+
# Facebook Login path: the linked Page speaks through the general Graph API host.
|
|
73
185
|
@api_base_url = "https://graph.facebook.com/v23.0"
|
|
186
|
+
# Instagram Login path: the Instagram professional account speaks through its
|
|
187
|
+
# own host instead, with no Facebook Page in the picture at all.
|
|
188
|
+
@instagram_login_api_base_url = "https://graph.instagram.com/v23.0"
|
|
189
|
+
# Meta: "Message text must be UTF-8 and be 1,000 bytes or less."
|
|
190
|
+
@max_text_length = 1000
|
|
191
|
+
@max_quick_replies = 13
|
|
192
|
+
@max_quick_reply_title = 20
|
|
193
|
+
@max_carousel_elements = 10
|
|
194
|
+
@max_buttons_per_element = 3
|
|
195
|
+
@max_button_title = 20
|
|
196
|
+
@max_element_title = 80
|
|
74
197
|
end
|
|
75
198
|
end
|
|
76
199
|
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
module FlowChat
|
|
2
|
+
# A reply the flow produced that the platform would not take.
|
|
3
|
+
#
|
|
4
|
+
# Raised by nothing: it exists so that a send which failed quietly, by
|
|
5
|
+
# answering nil rather than raising, still reaches on_delivery_failure and
|
|
6
|
+
# MESSAGE_DELIVERY_FAILED carrying something that names what happened. A
|
|
7
|
+
# subscriber written against a raising client sees the same shape either way.
|
|
8
|
+
class DeliveryError < StandardError; end
|
|
9
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
module FlowChat
|
|
2
|
+
module Http
|
|
3
|
+
# Its own file so Zeitwerk can resolve it by name. Declared inside
|
|
4
|
+
# gateway/simple.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
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
module FlowChat
|
|
2
2
|
module Http
|
|
3
|
-
class ConfigurationError < StandardError; end
|
|
4
|
-
|
|
5
3
|
module Gateway
|
|
6
4
|
class Simple
|
|
7
5
|
include FlowChat::Instrumentation
|
|
@@ -9,6 +7,17 @@ module FlowChat
|
|
|
9
7
|
|
|
10
8
|
attr_reader :context
|
|
11
9
|
|
|
10
|
+
# Configure HTTP specific middleware stack
|
|
11
|
+
def self.configure_middleware_stack(builder, custom_middleware)
|
|
12
|
+
FlowChat.logger.debug { "Simple: Configuring HTTP middleware stack" }
|
|
13
|
+
|
|
14
|
+
builder.use custom_middleware
|
|
15
|
+
FlowChat.logger.debug { "Simple: Added custom middleware" }
|
|
16
|
+
|
|
17
|
+
builder.use FlowChat::Http::Middleware::ChoiceMapper
|
|
18
|
+
FlowChat.logger.debug { "Simple: Added Http::Middleware::ChoiceMapper" }
|
|
19
|
+
end
|
|
20
|
+
|
|
12
21
|
def initialize(app, user_params)
|
|
13
22
|
@app = app
|
|
14
23
|
@user_params = user_params
|
|
@@ -46,8 +55,19 @@ module FlowChat
|
|
|
46
55
|
context["http.user_agent"] = request.user_agent
|
|
47
56
|
context.input = params["input"].presence || ""
|
|
48
57
|
|
|
49
|
-
#
|
|
50
|
-
if
|
|
58
|
+
# Inbound media (optional): callers may submit a media URL
|
|
59
|
+
if params["media_url"].present?
|
|
60
|
+
media_type = params["media_type"].presence&.to_sym
|
|
61
|
+
media_type = :document unless FlowChat::Media::CANONICAL_TYPES.include?(media_type)
|
|
62
|
+
context["request.media"] = {
|
|
63
|
+
type: media_type,
|
|
64
|
+
url: params["media_url"],
|
|
65
|
+
mime_type: params["mime_type"].presence
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Instrument message received when user provides input (text or media)
|
|
70
|
+
if inbound_message?(context)
|
|
51
71
|
instrument(Events::MESSAGE_RECEIVED, {
|
|
52
72
|
from: context["request.user_id"],
|
|
53
73
|
message: context.input,
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
module FlowChat
|
|
2
|
+
module Http
|
|
3
|
+
module Middleware
|
|
4
|
+
# Maps a web client's reply back to the choice it belongs to.
|
|
5
|
+
#
|
|
6
|
+
# A web client renders the choices itself, so what it sends back is
|
|
7
|
+
# whatever it decided to send: the key it was given, or the label it
|
|
8
|
+
# actually put on the button. Sending the label is the better of the two,
|
|
9
|
+
# because a transcript then reads back as what the visitor pressed rather
|
|
10
|
+
# than as an internal id, but the flow branches on keys.
|
|
11
|
+
#
|
|
12
|
+
# So both are accepted. Nothing is truncated here - the client decides
|
|
13
|
+
# its own widths - so a set of distinct labels is passed through exactly
|
|
14
|
+
# as the flow wrote it.
|
|
15
|
+
#
|
|
16
|
+
# Matching is exact, with no normalization on either side. A client
|
|
17
|
+
# echoes back the string it was handed rather than a person typing it,
|
|
18
|
+
# so nothing drifts on the way - and every transform that could have
|
|
19
|
+
# absorbed such a drift can also merge two choices into one entry, which
|
|
20
|
+
# is how the second of a pair became unpickable in the first place.
|
|
21
|
+
#
|
|
22
|
+
# The one thing that does get rewritten is a set whose labels are
|
|
23
|
+
# identical. Two choices both labelled "Savings" cannot be told apart by
|
|
24
|
+
# a visitor reading them either, so FlowChat::ChoiceTitles numbers the
|
|
25
|
+
# whole set. Previously the second simply lost to the first
|
|
26
|
+
# (`mapping[label] ||= key`) and could not be picked at all.
|
|
27
|
+
class ChoiceMapper
|
|
28
|
+
SESSION_KEY = "http.choice_mapping"
|
|
29
|
+
|
|
30
|
+
# The client renders these, so there is no platform width to fit.
|
|
31
|
+
UNCAPPED = Float::INFINITY
|
|
32
|
+
|
|
33
|
+
def initialize(app)
|
|
34
|
+
@app = app
|
|
35
|
+
FlowChat.logger.debug { "Http::ChoiceMapper: Initialized HTTP choice mapping middleware" }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def call(context)
|
|
39
|
+
resolve_input(context)
|
|
40
|
+
|
|
41
|
+
type, prompt, choices, media = @app.call(context)
|
|
42
|
+
|
|
43
|
+
choices = remember(context, choices)
|
|
44
|
+
|
|
45
|
+
[type, prompt, choices, media]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def resolve_input(context)
|
|
51
|
+
return if context.input.blank?
|
|
52
|
+
|
|
53
|
+
mapping = context.session.get(SESSION_KEY) || {}
|
|
54
|
+
return if mapping.empty?
|
|
55
|
+
|
|
56
|
+
matched = mapping[context.input.to_s]
|
|
57
|
+
return unless matched
|
|
58
|
+
|
|
59
|
+
FlowChat.logger.info { "Http::ChoiceMapper: Resolving #{context.input} to #{matched}" }
|
|
60
|
+
context.input = matched
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Displayed title to key. Cleared once a question carries no choices, so
|
|
64
|
+
# an answer to a later question is never read as a choice from an
|
|
65
|
+
# earlier one.
|
|
66
|
+
#
|
|
67
|
+
# Labels that survive the fold distinctly are returned untouched, which
|
|
68
|
+
# is the ordinary case. Only a set that would collide under the fold is
|
|
69
|
+
# numbered, and then the numbered titles are what the client is given -
|
|
70
|
+
# so what the visitor reads is what this resolves.
|
|
71
|
+
#
|
|
72
|
+
# @return [Hash, nil] the choices to render
|
|
73
|
+
def remember(context, choices)
|
|
74
|
+
if choices.blank?
|
|
75
|
+
context.session.delete(SESSION_KEY)
|
|
76
|
+
return choices
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
mapping = {}
|
|
80
|
+
titled = {}
|
|
81
|
+
|
|
82
|
+
FlowChat::ChoiceTitles.build(choices, UNCAPPED).each do |key, _label, title, _truncated|
|
|
83
|
+
titled[key] = title
|
|
84
|
+
mapping[title] = key
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
context.session.set(SESSION_KEY, mapping)
|
|
88
|
+
FlowChat.logger.debug { "Http::ChoiceMapper: Created mapping: #{mapping}" }
|
|
89
|
+
titled
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
module FlowChat
|
|
2
|
+
# A single inbound turn.
|
|
3
|
+
#
|
|
4
|
+
# Replaces the old "$media$"/"$location$"/"$contact$" sentinels: instead of
|
|
5
|
+
# overloading the input string with a control signal, a turn carries its text
|
|
6
|
+
# and (at most one) structured attachment as first-class fields.
|
|
7
|
+
#
|
|
8
|
+
# It behaves like its text wherever a string method is expected — so
|
|
9
|
+
# validators/transforms written for text (`input.strip`, `input.to_i`,
|
|
10
|
+
# `input.blank?`) keep working — while also exposing `#media`, `#location`,
|
|
11
|
+
# `#contact`, and `#attachment_type` for the attachment. The prompt gates on
|
|
12
|
+
# `#submitted?` (text OR attachment), so a caption-less photo still answers a
|
|
13
|
+
# screen even though its text is blank.
|
|
14
|
+
class Input
|
|
15
|
+
# Session marker for the "first message" gate. Not a turn signal — kept as a
|
|
16
|
+
# namespaced constant so `FlowChat::Input::START` continues to resolve.
|
|
17
|
+
START = "$start$"
|
|
18
|
+
|
|
19
|
+
# #media is always an Array<FlowChat::Media> (empty when none). It is a list
|
|
20
|
+
# even on single-media platforms so callers iterate uniformly and never
|
|
21
|
+
# silently drop the extra attachments a message can carry (e.g. Intercom).
|
|
22
|
+
attr_reader :text, :media, :location, :contact
|
|
23
|
+
|
|
24
|
+
def initialize(text: nil, media: nil, location: nil, contact: nil)
|
|
25
|
+
@text = text.nil? ? "" : text.to_s
|
|
26
|
+
@media = media || []
|
|
27
|
+
@location = location
|
|
28
|
+
@contact = contact
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Did the user send anything this turn — text OR an attachment? The prompt
|
|
32
|
+
# gates on this so a caption-less photo answers a screen. Distinct from
|
|
33
|
+
# #present?/#blank?, which follow the text (so text validators behave).
|
|
34
|
+
def submitted?
|
|
35
|
+
!@text.empty? || attachment?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def attachment?
|
|
39
|
+
!attachment_type.nil?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The structured payload on this turn — the media list, or the location /
|
|
43
|
+
# contact hash — or nil. Pair with #attachment_type to know which.
|
|
44
|
+
def attachment
|
|
45
|
+
case attachment_type
|
|
46
|
+
when :media then media
|
|
47
|
+
when :location then location
|
|
48
|
+
when :contact then contact
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The kind of structured payload on this turn, or nil. At most one is ever
|
|
53
|
+
# present in a single message, so this is a safe discriminator.
|
|
54
|
+
def attachment_type
|
|
55
|
+
return :media if @media.any?
|
|
56
|
+
return :location if @location
|
|
57
|
+
return :contact if @contact
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def to_s
|
|
62
|
+
@text
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def ==(other)
|
|
66
|
+
other = other.to_s if other.is_a?(FlowChat::Input)
|
|
67
|
+
@text == other
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Behave like the text for any other PUBLIC string method (strip, to_i,
|
|
71
|
+
# match?, length, empty?, ...), so text-oriented validators/transforms keep
|
|
72
|
+
# working. Private String methods are not exposed — respond_to_missing? and
|
|
73
|
+
# method_missing agree on public-only so `respond_to?` never lies.
|
|
74
|
+
def respond_to_missing?(name, include_private = false)
|
|
75
|
+
@text.respond_to?(name) || super
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def method_missing(name, *args, &block)
|
|
79
|
+
if @text.respond_to?(name)
|
|
80
|
+
@text.send(name, *args, &block)
|
|
81
|
+
else
|
|
82
|
+
super
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module FlowChat
|
|
2
|
+
module Instagram
|
|
3
|
+
class Client < FlowChat::Messenger::Client
|
|
4
|
+
private
|
|
5
|
+
|
|
6
|
+
def renderer_class
|
|
7
|
+
FlowChat::Instagram::Renderer
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def platform
|
|
11
|
+
:instagram
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def limits
|
|
15
|
+
FlowChat::Config.instagram
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Meta: "Message text must be UTF-8 and be 1,000 bytes or less." A
|
|
19
|
+
# character count would let multibyte text through to be rejected.
|
|
20
|
+
def measure(string)
|
|
21
|
+
string.bytesize
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Meta's Instagram send reference documents recipient and message only,
|
|
25
|
+
# with no messaging_type. Inheriting Messenger's would put an undocumented
|
|
26
|
+
# parameter on every Instagram send.
|
|
27
|
+
def messaging_type?
|
|
28
|
+
false
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
module FlowChat
|
|
2
|
+
module Instagram
|
|
3
|
+
class Configuration
|
|
4
|
+
include FlowChat::NamedConfiguration
|
|
5
|
+
|
|
6
|
+
# :facebook is the Instagram API with Facebook Login: the linked Page
|
|
7
|
+
# speaks through graph.facebook.com. :instagram is the Instagram API
|
|
8
|
+
# with Instagram Login: the Instagram professional account speaks for
|
|
9
|
+
# itself through graph.instagram.com, with no Page in the picture.
|
|
10
|
+
LOGIN_PATHS = [:facebook, :instagram].freeze
|
|
11
|
+
|
|
12
|
+
attr_accessor :access_token, :page_id, :instagram_account_id, :verify_token,
|
|
13
|
+
:app_id, :app_secret, :name, :skip_signature_validation
|
|
14
|
+
attr_reader :login
|
|
15
|
+
|
|
16
|
+
def initialize(name)
|
|
17
|
+
@name = name
|
|
18
|
+
@access_token = nil
|
|
19
|
+
@page_id = nil
|
|
20
|
+
@instagram_account_id = nil
|
|
21
|
+
@verify_token = nil
|
|
22
|
+
@app_id = nil
|
|
23
|
+
@app_secret = nil
|
|
24
|
+
@skip_signature_validation = false
|
|
25
|
+
@login = :facebook
|
|
26
|
+
|
|
27
|
+
FlowChat.logger.debug { "Instagram::Configuration: Initialized configuration with name: #{name || "anonymous"}" }
|
|
28
|
+
|
|
29
|
+
register_as(name) if name.present?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Rejected rather than coerced: a typo here would otherwise silently
|
|
33
|
+
# pick the wrong host and the wrong account identifier, and fail only
|
|
34
|
+
# once a real send or webhook hits the wrong Meta product.
|
|
35
|
+
def login=(value)
|
|
36
|
+
symbol = value&.to_sym
|
|
37
|
+
unless LOGIN_PATHS.include?(symbol)
|
|
38
|
+
raise ArgumentError, "login must be one of #{LOGIN_PATHS.inspect}, got #{value.inspect}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@login = symbol
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.from_credentials
|
|
45
|
+
FlowChat.logger.info { "Instagram::Configuration: Loading configuration from credentials/environment" }
|
|
46
|
+
|
|
47
|
+
config = new(nil)
|
|
48
|
+
|
|
49
|
+
if defined?(Rails) && Rails.respond_to?(:application) && Rails.application&.credentials&.instagram
|
|
50
|
+
FlowChat.logger.debug { "Instagram::Configuration: Loading from Rails credentials" }
|
|
51
|
+
credentials = Rails.application.credentials.instagram
|
|
52
|
+
config.access_token = credentials[:access_token]
|
|
53
|
+
config.page_id = credentials[:page_id]
|
|
54
|
+
config.instagram_account_id = credentials[:instagram_account_id]
|
|
55
|
+
config.verify_token = credentials[:verify_token]
|
|
56
|
+
config.app_id = credentials[:app_id]
|
|
57
|
+
config.app_secret = credentials[:app_secret]
|
|
58
|
+
config.skip_signature_validation = credentials[:skip_signature_validation] || false
|
|
59
|
+
config.login = (credentials[:login] || "facebook").to_sym
|
|
60
|
+
else
|
|
61
|
+
FlowChat.logger.debug { "Instagram::Configuration: Loading from environment variables" }
|
|
62
|
+
config.access_token = ENV["INSTAGRAM_ACCESS_TOKEN"]
|
|
63
|
+
config.page_id = ENV["INSTAGRAM_PAGE_ID"]
|
|
64
|
+
config.instagram_account_id = ENV["INSTAGRAM_ACCOUNT_ID"]
|
|
65
|
+
config.verify_token = ENV["INSTAGRAM_VERIFY_TOKEN"]
|
|
66
|
+
config.app_id = ENV["INSTAGRAM_APP_ID"]
|
|
67
|
+
config.app_secret = ENV["INSTAGRAM_APP_SECRET"]
|
|
68
|
+
config.skip_signature_validation = ENV["INSTAGRAM_SKIP_SIGNATURE_VALIDATION"] == "true"
|
|
69
|
+
config.login = (ENV["INSTAGRAM_LOGIN"] || "facebook").to_sym
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
if config.valid?
|
|
73
|
+
FlowChat.logger.info { "Instagram::Configuration: Configuration loaded successfully - #{config.login} login, account #{config.account_id}" }
|
|
74
|
+
else
|
|
75
|
+
FlowChat.logger.warn { "Instagram::Configuration: Incomplete configuration loaded - missing required fields" }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
config
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def valid?
|
|
82
|
+
# Both ids are required, because sending and receiving key on
|
|
83
|
+
# different ones and only on the :facebook path do they differ.
|
|
84
|
+
# account_id is what a send is addressed as (the Page there);
|
|
85
|
+
# instagram_account_id is what an inbound delivery names in entry.id
|
|
86
|
+
# on both paths, which is what webhook_account_id encodes.
|
|
87
|
+
#
|
|
88
|
+
# Checking only account_id passed a :facebook configuration that had
|
|
89
|
+
# never been given an instagram_account_id, and the gateway then
|
|
90
|
+
# rejected every delivery it received: the id it compares against was
|
|
91
|
+
# blank, and a blank expectation matches nothing. A configuration that
|
|
92
|
+
# answers the handshake and then refuses all traffic is worse than one
|
|
93
|
+
# that admits up front it is incomplete.
|
|
94
|
+
#
|
|
95
|
+
# Wrapped so a predicate answers true or false rather than nil, which
|
|
96
|
+
# the bare && chain returns for a missing first field. Intercom and
|
|
97
|
+
# Telegram already do this and pin it in their tests.
|
|
98
|
+
is_valid = !!(access_token && !access_token.to_s.empty? &&
|
|
99
|
+
verify_token && !verify_token.to_s.empty? &&
|
|
100
|
+
account_id && !account_id.to_s.empty? &&
|
|
101
|
+
webhook_account_id && !webhook_account_id.to_s.empty?)
|
|
102
|
+
|
|
103
|
+
FlowChat.logger.debug { "Instagram::Configuration: Configuration valid: #{is_valid}" }
|
|
104
|
+
is_valid
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# What a send is addressed to. An account reached through a Page answers
|
|
108
|
+
# as that Page over graph.facebook.com; an account with no Page answers
|
|
109
|
+
# for itself over graph.instagram.com. This is not the id an inbound
|
|
110
|
+
# delivery names, which is webhook_account_id below.
|
|
111
|
+
def account_id
|
|
112
|
+
(login == :instagram) ? instagram_account_id : page_id
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The id an inbound webhook's entry.id names, which is a different
|
|
116
|
+
# question from account_id above and has a different answer on the
|
|
117
|
+
# Facebook Login path.
|
|
118
|
+
#
|
|
119
|
+
# The top-level object of a delivery decides the id space, and this
|
|
120
|
+
# gateway only ever handles `instagram` (see expected_webhook_object),
|
|
121
|
+
# which names the Instagram professional account. That holds on both
|
|
122
|
+
# paths, so unlike account_id this does not depend on login.
|
|
123
|
+
def webhook_account_id
|
|
124
|
+
instagram_account_id
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def messages_url
|
|
128
|
+
"#{api_base_url}/#{account_id}/messages"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def attachment_upload_url
|
|
132
|
+
"#{api_base_url}/#{account_id}/message_attachments"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def api_base_url
|
|
136
|
+
(login == :instagram) ? FlowChat::Config.instagram.instagram_login_api_base_url : FlowChat::Config.instagram.api_base_url
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def api_headers
|
|
140
|
+
{
|
|
141
|
+
"Authorization" => "Bearer #{access_token}",
|
|
142
|
+
"Content-Type" => "application/json"
|
|
143
|
+
}
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -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
|