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
@@ -0,0 +1,121 @@
1
+ require "net/http"
2
+ require "uri"
3
+
4
+ module FlowChat
5
+ # Value object wrapping a single inbound media item parsed by a gateway.
6
+ # Normalizes cross-platform differences (WhatsApp media-id, Telegram file_id,
7
+ # Intercom/HTTP direct URL) behind #url and #download.
8
+ class Media
9
+ # Maps platform-native media types to a canonical, cross-platform set.
10
+ # Telegram uses :photo/:voice where WhatsApp uses :image/:audio.
11
+ NORMALIZED_TYPES = {photo: :image, voice: :audio}.freeze
12
+
13
+ # The canonical media types FlowChat recognizes across platforms. Gateways
14
+ # that accept a caller-supplied type (e.g. HTTP) validate against this set.
15
+ CANONICAL_TYPES = %i[image video audio document sticker].freeze
16
+
17
+ attr_reader :platform, :client
18
+
19
+ def initialize(data, platform:, client: nil)
20
+ @data = data
21
+ @platform = platform
22
+ @client = client
23
+ end
24
+
25
+ # Canonical, cross-platform media type (:image, :video, :audio, :document, :sticker).
26
+ def type
27
+ NORMALIZED_TYPES.fetch(raw_type, raw_type)
28
+ end
29
+
30
+ # The platform-native type as parsed by the gateway (e.g. :photo, :voice on Telegram).
31
+ def raw_type
32
+ @data[:type]
33
+ end
34
+
35
+ def mime_type
36
+ @data[:mime_type]
37
+ end
38
+
39
+ def caption
40
+ @data[:caption]
41
+ end
42
+
43
+ def filename
44
+ @data[:filename] || @data[:file_name]
45
+ end
46
+
47
+ def id
48
+ @data[:id]
49
+ end
50
+
51
+ def file_id
52
+ @data[:file_id]
53
+ end
54
+
55
+ def [](key)
56
+ @data[key]
57
+ end
58
+
59
+ def to_h
60
+ @data.dup
61
+ end
62
+
63
+ # Serialize without the live platform client (a network object that may not
64
+ # marshal). This keeps a Media — and any FlowChat::Input holding it — safe to
65
+ # store in a session store. A deserialized Media has no client, so #url and
66
+ # #download degrade to nil rather than raising.
67
+ def marshal_dump
68
+ {data: @data, platform: @platform}
69
+ end
70
+
71
+ def marshal_load(state)
72
+ @data = state[:data]
73
+ @platform = state[:platform]
74
+ @client = nil
75
+ end
76
+
77
+ # Resolve a fetchable URL for the media. Memoized so repeated reads don't
78
+ # re-issue the platform lookup (WhatsApp get_media_url / Telegram getFile).
79
+ # Returns nil (rather than raising) if the lookup fails, matching #download.
80
+ def url
81
+ @url ||= case platform
82
+ when :whatsapp then client.get_media_url(id)
83
+ when :telegram then client.file_url(file_id)
84
+ else @data[:url]
85
+ end
86
+ rescue => e
87
+ FlowChat.logger.warn { "Media: url resolution failed: #{e.message}" }
88
+ nil
89
+ end
90
+
91
+ # Fetch the raw bytes of the media, or nil on failure (uniform across
92
+ # platforms). WhatsApp needs the client to attach auth headers; every other
93
+ # platform (Telegram, Intercom, HTTP) exposes a token-in-URL or public link,
94
+ # so we fetch the already-memoized #url directly — avoiding a second getFile
95
+ # round-trip on Telegram.
96
+ def download
97
+ case platform
98
+ when :whatsapp then client.download_media(id)
99
+ else fetch(url)
100
+ end
101
+ rescue => e
102
+ FlowChat.logger.warn { "Media: download failed: #{e.message}" }
103
+ nil
104
+ end
105
+
106
+ private
107
+
108
+ def fetch(resource_url)
109
+ return nil unless resource_url
110
+
111
+ uri = URI(resource_url)
112
+ http = Net::HTTP.new(uri.host, uri.port)
113
+ http.use_ssl = (uri.scheme == "https")
114
+ response = http.get(uri.request_uri)
115
+ response.body if response.is_a?(Net::HTTPSuccess)
116
+ rescue => e
117
+ FlowChat.logger.warn { "Media: download failed for #{resource_url}: #{e.message}" }
118
+ nil
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,264 @@
1
+ require "net/http"
2
+ require "json"
3
+ require "uri"
4
+
5
+ module FlowChat
6
+ module Messenger
7
+ class Client
8
+ include FlowChat::Instrumentation
9
+
10
+ def initialize(config)
11
+ @config = config
12
+ FlowChat.logger.info { "Messenger::Client: Initialized for account #{@config.account_id}" }
13
+ end
14
+
15
+ # @param tag [String, nil] a Meta message tag (e.g. "HUMAN_AGENT") that
16
+ # extends the free-form send window beyond 24 hours. The application
17
+ # decides when a send qualifies; this only carries the value through
18
+ # to every part of the send. Passed through unvalidated: Meta accepts
19
+ # only HUMAN_AGENT as of 27 April 2026 and rejects anything else with
20
+ # error 100, clearly enough that an allowlist here would only be one
21
+ # more thing to keep in sync with Meta's own set.
22
+ def send_message(recipient_id, prompt, choices: nil, media: nil, tag: nil)
23
+ response = renderer_class.new(prompt, choices: choices, media: media).render
24
+ type, content, options = response
25
+
26
+ # MESSAGE_SENT is instrumented by the gateway, not here. This wrapped
27
+ # the send in its own instrument block, and ActiveSupport::Notifications
28
+ # publishes a block event once the block returns whatever it returned -
29
+ # so the event fired even when the send had failed and this method was
30
+ # about to answer nil, and fired a second time when the gateway
31
+ # instrumented the same send.
32
+ deliver(recipient_id, type, content, options, tag)
33
+ end
34
+
35
+ def send_text(recipient_id, text, tag: nil)
36
+ send_message(recipient_id, text, tag: tag)
37
+ end
38
+
39
+ # Shows the person a typing bubble while a turn is being worked out.
40
+ #
41
+ # Meta clears it when the next message arrives or after about twenty
42
+ # seconds, whichever comes first, so a caller holding one open for longer
43
+ # has to repeat it.
44
+ #
45
+ # Inherited by Instagram rather than overridden there. Meta documents
46
+ # sender actions under the Messenger Platform and lists only react and
47
+ # unreact for Instagram, but an Instagram send of typing_on is accepted
48
+ # and answered with the recipient id, same as Messenger. Confirmed
49
+ # against a live account, since the reference does not settle it.
50
+ def indicate_typing(recipient_id)
51
+ post_json(@config.messages_url, {recipient: {id: recipient_id}, sender_action: "typing_on"})
52
+ end
53
+
54
+ # Uploads a file for reuse and returns the id Meta assigned it.
55
+ def upload_media(url, type: :image)
56
+ payload = {
57
+ message: {
58
+ attachment: {
59
+ type: type.to_s,
60
+ payload: {url: url, is_reusable: true}
61
+ }
62
+ }
63
+ }
64
+
65
+ result = post_json(@config.attachment_upload_url, payload)
66
+ result && result["attachment_id"]
67
+ end
68
+
69
+ private
70
+
71
+ def renderer_class
72
+ FlowChat::Messenger::Renderer
73
+ end
74
+
75
+ def platform
76
+ :messenger
77
+ end
78
+
79
+ def limits
80
+ FlowChat::Config.messenger
81
+ end
82
+
83
+ # Anything over the platform's cap is rejected whole rather than trimmed by
84
+ # Meta, so long text goes as several messages. Only the last result is
85
+ # returned: it carries the id of the message the user ends up looking at.
86
+ #
87
+ # Media rides in options[:media] on every rung except :attachment, where
88
+ # it already is the message: it goes out as its own send, ahead of the
89
+ # choice message, because the choice surface is rendered exactly as it
90
+ # would be with no media at all. Every part - the media and every chunk
91
+ # of the choice message - carries the same tag.
92
+ def deliver(recipient_id, type, content, options, tag)
93
+ post_media(recipient_id, options[:media], tag) if options[:media]
94
+
95
+ case type
96
+ when :text
97
+ split_text(content).map { |chunk| post_message(recipient_id, {text: chunk}, tag) }.last
98
+ when :quick_replies
99
+ chunks = split_text(content)
100
+ # Quick replies belong on the final chunk, next to the question.
101
+ chunks[0..-2].each { |chunk| post_message(recipient_id, {text: chunk}, tag) }
102
+ post_message(recipient_id, {text: chunks.last, quick_replies: options[:quick_replies]}, tag)
103
+ when :carousel
104
+ post_body_text(recipient_id, content, tag)
105
+ post_message(recipient_id, {
106
+ attachment: {
107
+ type: "template",
108
+ payload: {template_type: "generic", elements: options[:elements]}
109
+ }
110
+ }, tag)
111
+ when :attachment
112
+ post_body_text(recipient_id, content, tag)
113
+ post_media(recipient_id, options, tag)
114
+ end
115
+ end
116
+
117
+ # content on these two branches is the numbered body Instagram's
118
+ # always_number? forces (or, on :attachment, just the flow's own
119
+ # message) - either can run over the platform's text cap on its own,
120
+ # with no relation to the template or attachment that follows, so it
121
+ # is split exactly like a plain text send rather than posted whole.
122
+ def post_body_text(recipient_id, content, tag)
123
+ return unless content.present?
124
+
125
+ split_text(content).each { |chunk| post_message(recipient_id, {text: chunk}, tag) }
126
+ end
127
+
128
+ def post_media(recipient_id, media_options, tag)
129
+ attachment_payload = media_options[:url] ? {url: media_options[:url], is_reusable: true} : {attachment_id: media_options[:attachment_id]}
130
+ post_message(recipient_id, {
131
+ attachment: {type: media_options[:type].to_s, payload: attachment_payload}
132
+ }, tag)
133
+ end
134
+
135
+ def post_message(recipient_id, message, tag)
136
+ payload = {recipient: {id: recipient_id}, message: message}
137
+
138
+ # The tag branch is not gated on messaging_type?: Instagram never
139
+ # documents RESPONSE, but it does document MESSAGE_TAG with
140
+ # HUMAN_AGENT, so a tagged send needs the field there too even
141
+ # though an untagged Instagram send omits it entirely.
142
+ if tag
143
+ payload[:messaging_type] = "MESSAGE_TAG"
144
+ payload[:tag] = tag
145
+ elsif messaging_type?
146
+ payload[:messaging_type] = "RESPONSE"
147
+ end
148
+
149
+ post_json(@config.messages_url, payload)
150
+ end
151
+
152
+ # Messenger documents messaging_type as required on a send. Instagram's
153
+ # reference does not mention it at all, so Instagram omits it rather than
154
+ # sending a parameter Meta never documented for that surface.
155
+ def messaging_type?
156
+ true
157
+ end
158
+
159
+ # Splits on whitespace so a word is never cut in half. Measured with the
160
+ # platform's own unit, which is bytes on Instagram and characters here.
161
+ #
162
+ # A single piece that is itself too large to fit in one chunk (a long
163
+ # URL, most often) cannot be handled by that whitespace splitting
164
+ # alone: with no smaller boundary inside it to break on, it would
165
+ # otherwise ride through untouched as one chunk over the cap, exactly
166
+ # the case this method exists to prevent. #hard_split below breaks it
167
+ # up directly.
168
+ def split_text(text)
169
+ limit = limits.max_text_length
170
+ return [text.to_s] if measure(text.to_s) <= limit
171
+
172
+ chunks = []
173
+ current = ""
174
+
175
+ text.to_s.split(/(\s+)/).each do |piece|
176
+ if measure(piece) > limit
177
+ chunks << current.strip if current.present?
178
+ oversized_chunks = hard_split(piece.strip, limit)
179
+ chunks.concat(oversized_chunks[0..-2])
180
+ current = oversized_chunks.last.to_s
181
+ elsif measure(current + piece) > limit && current.present?
182
+ chunks << current.strip
183
+ current = piece.lstrip
184
+ else
185
+ current += piece
186
+ end
187
+ end
188
+
189
+ chunks << current.strip if current.strip.present?
190
+ chunks
191
+ end
192
+
193
+ # Breaks a single oversized piece into cap-sized chunks one character
194
+ # at a time, so the cut always lands on a character boundary. Never a
195
+ # byte offset: on Instagram, where the cap is measured in bytes, a
196
+ # multibyte character sliced by byte position would leave one half a
197
+ # valid UTF-8 sequence and the other invalid, and #measure has no way
198
+ # to tell that apart from a character that legitimately does not fit.
199
+ def hard_split(piece, limit)
200
+ chunks = []
201
+ current = ""
202
+
203
+ piece.each_char do |char|
204
+ if current.present? && measure(current + char) > limit
205
+ chunks << current
206
+ current = char
207
+ else
208
+ current += char
209
+ end
210
+ end
211
+
212
+ chunks << current if current.present?
213
+ chunks
214
+ end
215
+
216
+ def measure(string)
217
+ string.length
218
+ end
219
+
220
+ def post_json(url, payload)
221
+ uri = URI(url)
222
+ http = Net::HTTP.new(uri.host, uri.port)
223
+ http.use_ssl = true
224
+
225
+ request = Net::HTTP::Post.new(uri)
226
+ @config.api_headers.each { |key, value| request[key] = value }
227
+ request.body = payload.to_json
228
+
229
+ response = http.request(request)
230
+
231
+ if response.is_a?(Net::HTTPSuccess)
232
+ JSON.parse(response.body)
233
+ else
234
+ FlowChat.logger.error { "#{self.class.name}: API request failed - #{response.code}: #{response.body}" }
235
+ report_api_error(
236
+ "#{platform} API request failed",
237
+ response_code: response.code,
238
+ response_body: response.body
239
+ )
240
+ nil
241
+ end
242
+ rescue Net::OpenTimeout, Net::ReadTimeout => network_error
243
+ FlowChat.logger.error { "#{self.class.name}: Network timeout: #{network_error.class.name}" }
244
+ raise network_error
245
+ end
246
+
247
+ # FlowChat::Instrumentation only defines report_api_error at the module
248
+ # level (FlowChat::Instrumentation.report_api_error), not as an instance
249
+ # method, so it is not inherited through `include`. Every client that
250
+ # wants the shorthand defines its own wrapper; this mirrors the one in
251
+ # whatsapp/client.rb.
252
+ def report_api_error(message, response_code: nil, response_body: nil, error: nil)
253
+ FlowChat::Instrumentation.report_api_error(
254
+ message,
255
+ error: error,
256
+ platform: platform,
257
+ account_id: @config.account_id,
258
+ response_code: response_code,
259
+ response_body: response_body
260
+ )
261
+ end
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,103 @@
1
+ module FlowChat
2
+ module Messenger
3
+ class Configuration
4
+ include FlowChat::NamedConfiguration
5
+
6
+ attr_accessor :access_token, :page_id, :verify_token, :app_id, :app_secret,
7
+ :name, :skip_signature_validation
8
+
9
+ def initialize(name)
10
+ @name = name
11
+ @access_token = nil
12
+ @page_id = nil
13
+ @verify_token = nil
14
+ @app_id = nil
15
+ @app_secret = nil
16
+ @skip_signature_validation = false
17
+
18
+ FlowChat.logger.debug { "Messenger::Configuration: Initialized configuration with name: #{name || "anonymous"}" }
19
+
20
+ register_as(name) if name.present?
21
+ end
22
+
23
+ def self.from_credentials
24
+ FlowChat.logger.info { "Messenger::Configuration: Loading configuration from credentials/environment" }
25
+
26
+ config = new(nil)
27
+
28
+ if defined?(Rails) && Rails.respond_to?(:application) && Rails.application&.credentials&.messenger
29
+ FlowChat.logger.debug { "Messenger::Configuration: Loading from Rails credentials" }
30
+ credentials = Rails.application.credentials.messenger
31
+ config.access_token = credentials[:access_token]
32
+ config.page_id = credentials[:page_id]
33
+ config.verify_token = credentials[:verify_token]
34
+ config.app_id = credentials[:app_id]
35
+ config.app_secret = credentials[:app_secret]
36
+ config.skip_signature_validation = credentials[:skip_signature_validation] || false
37
+ else
38
+ FlowChat.logger.debug { "Messenger::Configuration: Loading from environment variables" }
39
+ config.access_token = ENV["MESSENGER_ACCESS_TOKEN"]
40
+ config.page_id = ENV["MESSENGER_PAGE_ID"]
41
+ config.verify_token = ENV["MESSENGER_VERIFY_TOKEN"]
42
+ config.app_id = ENV["MESSENGER_APP_ID"]
43
+ config.app_secret = ENV["MESSENGER_APP_SECRET"]
44
+ config.skip_signature_validation = ENV["MESSENGER_SKIP_SIGNATURE_VALIDATION"] == "true"
45
+ end
46
+
47
+ if config.valid?
48
+ FlowChat.logger.info { "Messenger::Configuration: Configuration loaded successfully - page_id: #{config.page_id}" }
49
+ else
50
+ FlowChat.logger.warn { "Messenger::Configuration: Incomplete configuration loaded - missing required fields" }
51
+ end
52
+
53
+ config
54
+ end
55
+
56
+ def valid?
57
+ # Wrapped so a predicate answers true or false rather than nil, which
58
+ # the bare && chain returns for a missing first field. Intercom and
59
+ # Telegram already do this and pin it in their tests.
60
+ is_valid = !!(access_token && !access_token.to_s.empty? &&
61
+ page_id && !page_id.to_s.empty? &&
62
+ verify_token && !verify_token.to_s.empty?)
63
+
64
+ FlowChat.logger.debug { "Messenger::Configuration: Configuration valid: #{is_valid}" }
65
+ is_valid
66
+ end
67
+
68
+ # The account this configuration speaks for. Named generically, not
69
+ # page_id, so the shared gateway can check an inbound event's account
70
+ # without knowing which platform it holds.
71
+ def account_id
72
+ page_id
73
+ end
74
+
75
+ # The id an inbound webhook's entry.id names. Messenger only ever handles
76
+ # `page`, which names the Page, so this is the same id a send is
77
+ # addressed to. Instagram's two differ, which is why the gateway asks for
78
+ # this rather than reusing account_id.
79
+ def webhook_account_id
80
+ account_id
81
+ end
82
+
83
+ def messages_url
84
+ "#{api_base_url}/#{page_id}/messages"
85
+ end
86
+
87
+ def attachment_upload_url
88
+ "#{api_base_url}/#{page_id}/message_attachments"
89
+ end
90
+
91
+ def api_base_url
92
+ FlowChat::Config.messenger.api_base_url
93
+ end
94
+
95
+ def api_headers
96
+ {
97
+ "Authorization" => "Bearer #{access_token}",
98
+ "Content-Type" => "application/json"
99
+ }
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,9 @@
1
+ module FlowChat
2
+ module Messenger
3
+ # Its own file so Zeitwerk can resolve it by name. Declared inside
4
+ # configuration.rb it existed only once that file had loaded for some other
5
+ # reason, so an application rescuing it, or a test naming it before anything
6
+ # touched the configuration class, got an uninitialized constant instead.
7
+ class ConfigurationError < StandardError; end
8
+ end
9
+ end
@@ -0,0 +1,42 @@
1
+ module FlowChat
2
+ module Messenger
3
+ module Gateway
4
+ # Facebook Messenger, on the shared Messenger Platform envelope.
5
+ class SendApi < FlowChat::Meta::MessagingGateway
6
+ def platform
7
+ :messenger
8
+ end
9
+
10
+ def gateway_name
11
+ :messenger_send_api
12
+ end
13
+
14
+ def configuration_class
15
+ FlowChat::Messenger::Configuration
16
+ end
17
+
18
+ def client_class
19
+ FlowChat::Messenger::Client
20
+ end
21
+
22
+ def renderer_class
23
+ FlowChat::Messenger::Renderer
24
+ end
25
+
26
+ def self.choice_mapper_class
27
+ FlowChat::Messenger::Middleware::ChoiceMapper
28
+ end
29
+
30
+ private
31
+
32
+ def configuration_error_class
33
+ FlowChat::Messenger::ConfigurationError
34
+ end
35
+
36
+ def platform_label
37
+ "Messenger"
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end