flow_chat 0.8.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. checksums.yaml +4 -4
  2. data/.cliff.toml +74 -0
  3. data/.github/workflows/ci.yml +2 -3
  4. data/.github/workflows/pages.yml +43 -0
  5. data/.github/workflows/release.yml +56 -0
  6. data/.standard.yml +4 -0
  7. data/CHANGELOG.md +48 -0
  8. data/CLAUDE.md +327 -0
  9. data/CONTRIBUTING.md +134 -0
  10. data/Gemfile +1 -0
  11. data/README.md +189 -133
  12. data/Rakefile +17 -2
  13. data/SECURITY.md +42 -349
  14. data/docs/architecture.md +83 -0
  15. data/docs/async-background-processing.md +64 -0
  16. data/docs/configuration.md +110 -287
  17. data/docs/factory-pattern.md +58 -0
  18. data/docs/gateway-context-variables.md +168 -0
  19. data/docs/gateway-development.md +159 -0
  20. data/docs/getting-started.md +90 -0
  21. data/docs/instrumentation.md +95 -175
  22. data/docs/platforms/instagram.md +278 -0
  23. data/docs/platforms/messenger.md +205 -0
  24. data/docs/platforms/telegram.md +109 -0
  25. data/docs/platforms/ussd.md +78 -0
  26. data/docs/platforms/whatsapp.md +147 -0
  27. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md +732 -0
  28. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json +58 -0
  29. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md +4064 -0
  30. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json +226 -0
  31. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md +972 -0
  32. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json +88 -0
  33. data/docs/superpowers/specs/2026-07-09-inbound-media-support-design.md +195 -0
  34. data/docs/superpowers/specs/2026-08-10-messenger-instagram-design.md +391 -0
  35. data/docs/testing.md +33 -426
  36. data/examples/custom_session_id_example.rb +119 -0
  37. data/examples/http_controller.rb +22 -20
  38. data/examples/intercom_configuration_example.rb +113 -0
  39. data/examples/intercom_controller.rb +182 -0
  40. data/examples/multi_tenant_whatsapp_controller.rb +63 -168
  41. data/examples/simulator_controller.rb +0 -1
  42. data/examples/ussd_controller.rb +88 -160
  43. data/examples/whatsapp_controller.rb +18 -17
  44. data/examples/whatsapp_media_examples.rb +27 -79
  45. data/flow_chat.gemspec +4 -0
  46. data/lib/flow_chat/app.rb +211 -0
  47. data/lib/flow_chat/async_job.rb +176 -0
  48. data/lib/flow_chat/choice_titles.rb +95 -0
  49. data/lib/flow_chat/config.rb +126 -23
  50. data/lib/flow_chat/delivery_error.rb +9 -0
  51. data/lib/flow_chat/{base_executor.rb → executor.rb} +6 -11
  52. data/lib/flow_chat/factory.rb +94 -0
  53. data/lib/flow_chat/gateway_async_support.rb +106 -0
  54. data/lib/flow_chat/generic_async_job.rb +30 -0
  55. data/lib/flow_chat/http/configuration_error.rb +9 -0
  56. data/lib/flow_chat/http/gateway/simple.rb +104 -36
  57. data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
  58. data/lib/flow_chat/http/renderer.rb +3 -3
  59. data/lib/flow_chat/input.rb +86 -0
  60. data/lib/flow_chat/instagram/client.rb +32 -0
  61. data/lib/flow_chat/instagram/configuration.rb +147 -0
  62. data/lib/flow_chat/instagram/configuration_error.rb +7 -0
  63. data/lib/flow_chat/instagram/gateway/send_api.rb +63 -0
  64. data/lib/flow_chat/instagram/middleware/choice_mapper.rb +22 -0
  65. data/lib/flow_chat/instagram/renderer.rb +23 -0
  66. data/lib/flow_chat/instrumentation/metrics_collector.rb +6 -1
  67. data/lib/flow_chat/instrumentation/setup.rb +1 -1
  68. data/lib/flow_chat/instrumentation.rb +182 -0
  69. data/lib/flow_chat/intercom/client.rb +161 -0
  70. data/lib/flow_chat/intercom/configuration.rb +102 -0
  71. data/lib/flow_chat/intercom/configuration_error.rb +9 -0
  72. data/lib/flow_chat/intercom/gateway/intercom_api.rb +420 -0
  73. data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
  74. data/lib/flow_chat/intercom/renderer.rb +123 -0
  75. data/lib/flow_chat/media.rb +121 -0
  76. data/lib/flow_chat/messenger/client.rb +264 -0
  77. data/lib/flow_chat/messenger/configuration.rb +103 -0
  78. data/lib/flow_chat/messenger/configuration_error.rb +9 -0
  79. data/lib/flow_chat/messenger/gateway/send_api.rb +42 -0
  80. data/lib/flow_chat/messenger/middleware/choice_mapper.rb +185 -0
  81. data/lib/flow_chat/messenger/renderer.rb +150 -0
  82. data/lib/flow_chat/meta/challenge.rb +24 -0
  83. data/lib/flow_chat/meta/choice_ladder.rb +37 -0
  84. data/lib/flow_chat/meta/configuration_error.rb +7 -0
  85. data/lib/flow_chat/meta/gateway_identity.rb +38 -0
  86. data/lib/flow_chat/meta/messaging_gateway.rb +468 -0
  87. data/lib/flow_chat/meta/signature.rb +30 -0
  88. data/lib/flow_chat/meta/signature_validation.rb +66 -0
  89. data/lib/flow_chat/meta/webhook_verification.rb +43 -0
  90. data/lib/flow_chat/named_configuration.rb +65 -0
  91. data/lib/flow_chat/phone_number_util.rb +37 -35
  92. data/lib/flow_chat/processor.rb +188 -0
  93. data/lib/flow_chat/prompt.rb +13 -16
  94. data/lib/flow_chat/renderers/markdown_support.rb +167 -0
  95. data/lib/flow_chat/security.rb +76 -0
  96. data/lib/flow_chat/session/middleware.rb +36 -11
  97. data/lib/flow_chat/simulator/controller.rb +31 -15
  98. data/lib/flow_chat/simulator/views/simulator.html.erb +184 -20
  99. data/lib/flow_chat/telegram/client.rb +283 -0
  100. data/lib/flow_chat/telegram/configuration.rb +78 -0
  101. data/lib/flow_chat/telegram/configuration_error.rb +9 -0
  102. data/lib/flow_chat/telegram/gateway/bot_api.rb +318 -0
  103. data/lib/flow_chat/telegram/middleware/choice_mapper.rb +96 -0
  104. data/lib/flow_chat/telegram/renderer.rb +133 -0
  105. data/lib/flow_chat/telegram.rb +7 -0
  106. data/lib/flow_chat/text_truncator.rb +75 -0
  107. data/lib/flow_chat/ussd/gateway/nalo.rb +24 -4
  108. data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
  109. data/lib/flow_chat/ussd/middleware/pagination.rb +9 -5
  110. data/lib/flow_chat/ussd/renderer.rb +1 -1
  111. data/lib/flow_chat/version.rb +1 -1
  112. data/lib/flow_chat/whatsapp/client.rb +158 -20
  113. data/lib/flow_chat/whatsapp/configuration.rb +13 -52
  114. data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
  115. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +335 -248
  116. data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +234 -0
  117. data/lib/flow_chat/whatsapp/renderer.rb +259 -64
  118. data/lib/flow_chat.rb +1 -1
  119. data/lib/tasks/release.rake +165 -0
  120. data/site/.nojekyll +0 -0
  121. data/site/.og-card.html +89 -0
  122. data/site/favicon.svg +6 -0
  123. data/site/index.html +209 -0
  124. data/site/og.png +0 -0
  125. metadata +132 -25
  126. data/docs/flows.md +0 -320
  127. data/docs/http-gateway-protocol.md +0 -432
  128. data/docs/images/simulator.png +0 -0
  129. data/docs/media.md +0 -153
  130. data/docs/sessions.md +0 -433
  131. data/docs/ussd-setup.md +0 -322
  132. data/docs/whatsapp-setup.md +0 -162
  133. data/examples/whatsapp_message_job.rb +0 -113
  134. data/lib/flow_chat/base_app.rb +0 -86
  135. data/lib/flow_chat/base_processor.rb +0 -146
  136. data/lib/flow_chat/http/app.rb +0 -6
  137. data/lib/flow_chat/http/middleware/executor.rb +0 -24
  138. data/lib/flow_chat/http/processor.rb +0 -33
  139. data/lib/flow_chat/session/rails_session_store.rb +0 -68
  140. data/lib/flow_chat/ussd/app.rb +0 -6
  141. data/lib/flow_chat/ussd/gateway/nsano.rb +0 -96
  142. data/lib/flow_chat/ussd/middleware/executor.rb +0 -24
  143. data/lib/flow_chat/ussd/processor.rb +0 -39
  144. data/lib/flow_chat/whatsapp/app.rb +0 -29
  145. data/lib/flow_chat/whatsapp/middleware/executor.rb +0 -24
  146. data/lib/flow_chat/whatsapp/processor.rb +0 -32
  147. data/lib/flow_chat/whatsapp/send_job_support.rb +0 -79
@@ -3,34 +3,71 @@ module FlowChat
3
3
  module Gateway
4
4
  class Simple
5
5
  include FlowChat::Instrumentation
6
+ include FlowChat::GatewayAsyncSupport
6
7
 
7
8
  attr_reader :context
8
9
 
9
- def initialize(app)
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
+
21
+ def initialize(app, user_params)
10
22
  @app = app
23
+ @user_params = user_params
24
+
25
+ validate_user_params!
11
26
  end
12
27
 
13
28
  def call(context)
14
29
  @context = context
15
- params = context.controller.request.params
16
- request = context.controller.request
17
-
18
- # Extract basic request information
19
- context["request.id"] = params["session_id"] || SecureRandom.uuid
20
- context["request.msisdn"] = FlowChat::PhoneNumberUtil.to_e164(params["msisdn"])
21
- context["request.user_id"] = params["user_id"] || context["request.msisdn"] || context["request.id"]
22
- context["request.message_id"] = params["message_id"] || SecureRandom.uuid
30
+ @controller = context.controller
31
+ params = @controller.request.params
32
+ request = @controller.request
33
+
34
+ # Validate request method
35
+ unless request.get? || request.post?
36
+ @controller.head :bad_request
37
+ return
38
+ end
39
+
40
+ # Set request information from user_params
41
+ context["request.id"] = @user_params[:session_id]
42
+ context["request.user_id"] = @user_params[:user_id]
43
+ context["request.user_name"] = @user_params[:name] if @user_params[:name]
44
+ context["request.msisdn"] = @user_params[:msisdn] if @user_params[:msisdn]
45
+ context["request.email"] = @user_params[:email] if @user_params[:email]
46
+ context["request.message_id"] = SecureRandom.uuid
23
47
  context["request.timestamp"] = Time.current.iso8601
24
48
  context["request.gateway"] = :http_simple
25
49
  context["request.platform"] = :http
26
- context["request.network"] = nil
27
- context["request.method"] = request.method
28
- context["request.path"] = request.path
29
- context["request.user_agent"] = request.user_agent
30
- context.input = params["input"] || params["message"]
31
-
32
- # Instrument message received when user provides input
33
- if context.input.present?
50
+ context["request.body"] = (params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params.to_h).transform_keys(&:to_s)
51
+
52
+ # HTTP-specific request metadata
53
+ context["http.method"] = request.method
54
+ context["http.path"] = request.path
55
+ context["http.user_agent"] = request.user_agent
56
+ context.input = params["input"].presence || ""
57
+
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)
34
71
  instrument(Events::MESSAGE_RECEIVED, {
35
72
  from: context["request.user_id"],
36
73
  message: context.input,
@@ -38,31 +75,62 @@ module FlowChat
38
75
  })
39
76
  end
40
77
 
41
- # Process the request
42
- type, prompt, choices, media = @app.call(context)
78
+ # Determine routing: async enqueue, background execute, or inline
79
+ if should_enqueue_async?
80
+ # HTTP request with async enabled → enqueue job and return immediately
81
+ enqueue_async_job
82
+ @controller.render json: {status: "processing"}
83
+ else
84
+ # Background OR inline → process message
85
+ # Process the request
86
+ response = @app.call(context)
43
87
 
44
- # Instrument message sent
45
- instrument(Events::MESSAGE_SENT, {
46
- to: context["request.user_id"],
47
- session_id: context["request.id"],
48
- message: context.input || "",
49
- message_type: (type == :prompt) ? "prompt" : "terminal",
50
- gateway: :http_simple,
51
- platform: :http,
52
- content_length: prompt.to_s.length,
53
- timestamp: context["request.timestamp"]
54
- })
55
-
56
- # Render response as JSON
57
- response_data = render_response(type, prompt, choices, media)
58
- context.controller.render json: response_data
88
+ # Handle nil response (e.g., from middleware that handles the response itself)
89
+ unless response
90
+ return @controller.render json: {
91
+ type: :skip,
92
+ session_id: context["request.id"],
93
+ user_id: context["request.user_id"],
94
+ timestamp: context["request.timestamp"]
95
+ }
96
+ end
97
+
98
+ type, prompt, choices, media = response
99
+
100
+ # Instrument message sent
101
+ instrument(Events::MESSAGE_SENT, {
102
+ to: context["request.user_id"],
103
+ session_id: context["request.id"],
104
+ message: context.input || "",
105
+ message_type: (type == :prompt) ? "prompt" : "terminal",
106
+ gateway: :http_simple,
107
+ platform: :http,
108
+ content_length: prompt.to_s.length,
109
+ timestamp: context["request.timestamp"]
110
+ })
111
+
112
+ # Render response as JSON
113
+ response_data = render_response(type, prompt, choices, media)
114
+ @controller.render json: response_data
115
+ end
59
116
  end
60
117
 
61
118
  private
62
119
 
120
+ def validate_user_params!
121
+ required_keys = [:session_id, :user_id]
122
+
123
+ required_keys.each do |key|
124
+ unless @user_params.key?(key)
125
+ raise FlowChat::Http::ConfigurationError,
126
+ "HTTP Simple gateway requires :#{key} in user_params"
127
+ end
128
+ end
129
+ end
130
+
63
131
  def render_response(type, prompt, choices, media)
64
132
  rendered = FlowChat::Http::Renderer.new(prompt, choices: choices, media: media).render
65
-
133
+
66
134
  {
67
135
  type: type,
68
136
  session_id: context["request.id"],
@@ -74,4 +142,4 @@ module FlowChat
74
142
  end
75
143
  end
76
144
  end
77
- end
145
+ end
@@ -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
@@ -24,18 +24,18 @@ module FlowChat
24
24
  def format_choices
25
25
  return unless choices.present?
26
26
 
27
- choices.map { |key, value| { key: key, value: value } }
27
+ choices.map { |key, value| {key: key, value: value} }
28
28
  end
29
29
 
30
30
  def format_media
31
31
  return unless media.present?
32
32
 
33
33
  {
34
- url: media[:url] || media[:path],
34
+ url: media[:url],
35
35
  type: media[:type] || :image,
36
36
  caption: media[:caption]
37
37
  }.compact
38
38
  end
39
39
  end
40
40
  end
41
- end
41
+ 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
@@ -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