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.
Files changed (103) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +43 -0
  3. data/CHANGELOG.md +26 -0
  4. data/README.md +166 -295
  5. data/Rakefile +12 -1
  6. data/SECURITY.md +1 -1
  7. data/docs/architecture.md +52 -479
  8. data/docs/async-background-processing.md +31 -265
  9. data/docs/configuration.md +106 -613
  10. data/docs/factory-pattern.md +27 -324
  11. data/docs/gateway-context-variables.md +140 -143
  12. data/docs/gateway-development.md +86 -650
  13. data/docs/getting-started.md +40 -379
  14. data/docs/instrumentation.md +88 -279
  15. data/docs/platforms/instagram.md +278 -0
  16. data/docs/platforms/messenger.md +205 -0
  17. data/docs/platforms/telegram.md +47 -951
  18. data/docs/platforms/ussd.md +38 -653
  19. data/docs/platforms/whatsapp.md +73 -1321
  20. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md +732 -0
  21. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json +58 -0
  22. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md +4064 -0
  23. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json +226 -0
  24. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md +972 -0
  25. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json +88 -0
  26. data/docs/superpowers/specs/2026-07-09-inbound-media-support-design.md +195 -0
  27. data/docs/superpowers/specs/2026-08-10-messenger-instagram-design.md +391 -0
  28. data/docs/testing.md +26 -297
  29. data/examples/http_controller.rb +12 -10
  30. data/examples/intercom_configuration_example.rb +19 -24
  31. data/examples/intercom_controller.rb +8 -20
  32. data/examples/multi_tenant_whatsapp_controller.rb +61 -166
  33. data/examples/simulator_controller.rb +0 -1
  34. data/examples/ussd_controller.rb +86 -158
  35. data/examples/whatsapp_controller.rb +16 -15
  36. data/examples/whatsapp_media_examples.rb +27 -79
  37. data/lib/flow_chat/app.rb +129 -11
  38. data/lib/flow_chat/choice_titles.rb +95 -0
  39. data/lib/flow_chat/config.rb +124 -1
  40. data/lib/flow_chat/delivery_error.rb +9 -0
  41. data/lib/flow_chat/http/configuration_error.rb +9 -0
  42. data/lib/flow_chat/http/gateway/simple.rb +24 -4
  43. data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
  44. data/lib/flow_chat/input.rb +86 -0
  45. data/lib/flow_chat/instagram/client.rb +32 -0
  46. data/lib/flow_chat/instagram/configuration.rb +147 -0
  47. data/lib/flow_chat/instagram/configuration_error.rb +7 -0
  48. data/lib/flow_chat/instagram/gateway/send_api.rb +63 -0
  49. data/lib/flow_chat/instagram/middleware/choice_mapper.rb +22 -0
  50. data/lib/flow_chat/instagram/renderer.rb +23 -0
  51. data/lib/flow_chat/instrumentation/metrics_collector.rb +6 -1
  52. data/lib/flow_chat/instrumentation.rb +160 -1
  53. data/lib/flow_chat/intercom/client.rb +34 -28
  54. data/lib/flow_chat/intercom/configuration.rb +2 -49
  55. data/lib/flow_chat/intercom/configuration_error.rb +9 -0
  56. data/lib/flow_chat/intercom/gateway/intercom_api.rb +81 -57
  57. data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
  58. data/lib/flow_chat/intercom/renderer.rb +57 -5
  59. data/lib/flow_chat/media.rb +121 -0
  60. data/lib/flow_chat/messenger/client.rb +264 -0
  61. data/lib/flow_chat/messenger/configuration.rb +103 -0
  62. data/lib/flow_chat/messenger/configuration_error.rb +9 -0
  63. data/lib/flow_chat/messenger/gateway/send_api.rb +42 -0
  64. data/lib/flow_chat/messenger/middleware/choice_mapper.rb +185 -0
  65. data/lib/flow_chat/messenger/renderer.rb +150 -0
  66. data/lib/flow_chat/meta/challenge.rb +24 -0
  67. data/lib/flow_chat/meta/choice_ladder.rb +37 -0
  68. data/lib/flow_chat/meta/configuration_error.rb +7 -0
  69. data/lib/flow_chat/meta/gateway_identity.rb +38 -0
  70. data/lib/flow_chat/meta/messaging_gateway.rb +468 -0
  71. data/lib/flow_chat/meta/signature.rb +30 -0
  72. data/lib/flow_chat/meta/signature_validation.rb +66 -0
  73. data/lib/flow_chat/meta/webhook_verification.rb +43 -0
  74. data/lib/flow_chat/named_configuration.rb +65 -0
  75. data/lib/flow_chat/prompt.rb +13 -16
  76. data/lib/flow_chat/renderers/markdown_support.rb +109 -0
  77. data/lib/flow_chat/security.rb +76 -0
  78. data/lib/flow_chat/session/middleware.rb +11 -2
  79. data/lib/flow_chat/simulator/controller.rb +31 -15
  80. data/lib/flow_chat/simulator/views/simulator.html.erb +184 -20
  81. data/lib/flow_chat/telegram/client.rb +47 -4
  82. data/lib/flow_chat/telegram/configuration.rb +2 -42
  83. data/lib/flow_chat/telegram/configuration_error.rb +9 -0
  84. data/lib/flow_chat/telegram/gateway/bot_api.rb +46 -28
  85. data/lib/flow_chat/telegram/middleware/choice_mapper.rb +77 -16
  86. data/lib/flow_chat/telegram/renderer.rb +10 -2
  87. data/lib/flow_chat/text_truncator.rb +75 -0
  88. data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
  89. data/lib/flow_chat/version.rb +1 -1
  90. data/lib/flow_chat/whatsapp/client.rb +14 -7
  91. data/lib/flow_chat/whatsapp/configuration.rb +12 -51
  92. data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
  93. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +254 -203
  94. data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +144 -57
  95. data/lib/flow_chat/whatsapp/renderer.rb +121 -60
  96. data/lib/flow_chat.rb +0 -10
  97. data/site/.nojekyll +0 -0
  98. data/site/.og-card.html +89 -0
  99. data/site/favicon.svg +6 -0
  100. data/site/index.html +209 -0
  101. data/site/og.png +0 -0
  102. metadata +51 -3
  103. data/lib/flow_chat/whatsapp/id_generator.rb +0 -124
@@ -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
- attr_reader :api_base_url
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
- # Instrument message received when user provides input
50
- if context.input.present?
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,7 @@
1
+ module FlowChat
2
+ module Instagram
3
+ # Its own file so Zeitwerk can resolve it by name. See the note on
4
+ # FlowChat::Messenger::ConfigurationError.
5
+ class ConfigurationError < StandardError; end
6
+ end
7
+ 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