flow_chat 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +29 -0
  3. data/.github/workflows/pages.yml +43 -0
  4. data/.gitignore +4 -1
  5. data/CHANGELOG.md +49 -0
  6. data/README.md +166 -295
  7. data/Rakefile +12 -1
  8. data/SECURITY.md +1 -1
  9. data/docs/architecture.md +52 -479
  10. data/docs/async-background-processing.md +31 -265
  11. data/docs/configuration.md +106 -613
  12. data/docs/factory-pattern.md +27 -324
  13. data/docs/gateway-context-variables.md +140 -143
  14. data/docs/gateway-development.md +86 -650
  15. data/docs/getting-started.md +40 -379
  16. data/docs/instrumentation.md +88 -279
  17. data/docs/platforms/instagram.md +278 -0
  18. data/docs/platforms/messenger.md +205 -0
  19. data/docs/platforms/telegram.md +47 -951
  20. data/docs/platforms/ussd.md +38 -653
  21. data/docs/platforms/whatsapp.md +73 -1321
  22. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md +732 -0
  23. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json +58 -0
  24. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md +4064 -0
  25. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json +226 -0
  26. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md +972 -0
  27. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json +88 -0
  28. data/docs/superpowers/specs/2026-07-09-inbound-media-support-design.md +195 -0
  29. data/docs/superpowers/specs/2026-08-10-messenger-instagram-design.md +391 -0
  30. data/docs/testing.md +26 -297
  31. data/examples/http_controller.rb +12 -10
  32. data/examples/intercom_configuration_example.rb +19 -24
  33. data/examples/intercom_controller.rb +8 -20
  34. data/examples/multi_tenant_whatsapp_controller.rb +61 -166
  35. data/examples/simulator_controller.rb +0 -1
  36. data/examples/ussd_controller.rb +86 -158
  37. data/examples/whatsapp_controller.rb +16 -15
  38. data/examples/whatsapp_media_examples.rb +27 -79
  39. data/flow_chat.gemspec +22 -2
  40. data/gemfiles/rails_main.gemfile +18 -0
  41. data/lib/flow_chat/app.rb +129 -11
  42. data/lib/flow_chat/async_job.rb +10 -0
  43. data/lib/flow_chat/choice_titles.rb +95 -0
  44. data/lib/flow_chat/config.rb +124 -1
  45. data/lib/flow_chat/delivery_error.rb +9 -0
  46. data/lib/flow_chat/gateway_async_support.rb +19 -1
  47. data/lib/flow_chat/http/configuration_error.rb +9 -0
  48. data/lib/flow_chat/http/gateway/simple.rb +24 -4
  49. data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
  50. data/lib/flow_chat/input.rb +86 -0
  51. data/lib/flow_chat/instagram/client.rb +32 -0
  52. data/lib/flow_chat/instagram/configuration.rb +147 -0
  53. data/lib/flow_chat/instagram/configuration_error.rb +7 -0
  54. data/lib/flow_chat/instagram/gateway/send_api.rb +63 -0
  55. data/lib/flow_chat/instagram/middleware/choice_mapper.rb +22 -0
  56. data/lib/flow_chat/instagram/renderer.rb +23 -0
  57. data/lib/flow_chat/instrumentation/metrics_collector.rb +6 -1
  58. data/lib/flow_chat/instrumentation.rb +160 -1
  59. data/lib/flow_chat/intercom/client.rb +34 -28
  60. data/lib/flow_chat/intercom/configuration.rb +2 -49
  61. data/lib/flow_chat/intercom/configuration_error.rb +9 -0
  62. data/lib/flow_chat/intercom/gateway/intercom_api.rb +81 -57
  63. data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
  64. data/lib/flow_chat/intercom/renderer.rb +57 -5
  65. data/lib/flow_chat/media.rb +121 -0
  66. data/lib/flow_chat/messenger/client.rb +264 -0
  67. data/lib/flow_chat/messenger/configuration.rb +103 -0
  68. data/lib/flow_chat/messenger/configuration_error.rb +9 -0
  69. data/lib/flow_chat/messenger/gateway/send_api.rb +42 -0
  70. data/lib/flow_chat/messenger/middleware/choice_mapper.rb +185 -0
  71. data/lib/flow_chat/messenger/renderer.rb +150 -0
  72. data/lib/flow_chat/meta/challenge.rb +24 -0
  73. data/lib/flow_chat/meta/choice_ladder.rb +37 -0
  74. data/lib/flow_chat/meta/configuration_error.rb +7 -0
  75. data/lib/flow_chat/meta/gateway_identity.rb +38 -0
  76. data/lib/flow_chat/meta/messaging_gateway.rb +471 -0
  77. data/lib/flow_chat/meta/signature.rb +30 -0
  78. data/lib/flow_chat/meta/signature_validation.rb +66 -0
  79. data/lib/flow_chat/meta/webhook_verification.rb +43 -0
  80. data/lib/flow_chat/named_configuration.rb +65 -0
  81. data/lib/flow_chat/prompt.rb +13 -16
  82. data/lib/flow_chat/renderers/markdown_support.rb +109 -0
  83. data/lib/flow_chat/security.rb +76 -0
  84. data/lib/flow_chat/session/middleware.rb +11 -2
  85. data/lib/flow_chat/simulator/controller.rb +31 -15
  86. data/lib/flow_chat/simulator/views/simulator.html.erb +184 -20
  87. data/lib/flow_chat/telegram/client.rb +47 -4
  88. data/lib/flow_chat/telegram/configuration.rb +2 -42
  89. data/lib/flow_chat/telegram/configuration_error.rb +9 -0
  90. data/lib/flow_chat/telegram/gateway/bot_api.rb +46 -28
  91. data/lib/flow_chat/telegram/middleware/choice_mapper.rb +77 -16
  92. data/lib/flow_chat/telegram/renderer.rb +10 -2
  93. data/lib/flow_chat/text_truncator.rb +75 -0
  94. data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
  95. data/lib/flow_chat/version.rb +1 -1
  96. data/lib/flow_chat/whatsapp/client.rb +14 -7
  97. data/lib/flow_chat/whatsapp/configuration.rb +12 -51
  98. data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
  99. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +259 -203
  100. data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +144 -57
  101. data/lib/flow_chat/whatsapp/renderer.rb +121 -60
  102. data/lib/flow_chat.rb +0 -10
  103. data/lib/tasks/release.rake +11 -1
  104. data/site/.nojekyll +0 -0
  105. data/site/.og-card.html +89 -0
  106. data/site/favicon.svg +6 -0
  107. data/site/index.html +209 -0
  108. data/site/og.png +0 -0
  109. metadata +60 -11
  110. data/lib/flow_chat/whatsapp/id_generator.rb +0 -124
@@ -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
@@ -0,0 +1,185 @@
1
+ module FlowChat
2
+ module Messenger
3
+ module Middleware
4
+ # Maps a reply back to the choice key the flow used.
5
+ #
6
+ # Two key spaces can be live at once. A tap sends the payload the
7
+ # renderer put on the button, which is the title shown on it, and a user
8
+ # who types what they read sends that same string - so both resolve
9
+ # through one map. Only when a number is genuinely on screen does a
10
+ # typed digit mean a position, which is the second space.
11
+ #
12
+ # The title is the payload rather than a separately generated id
13
+ # because FlowChat::ChoiceTitles already guarantees the titles in a set
14
+ # are distinct, numbering the set when they would not be. A generated
15
+ # id needed its own uniqueness rule, and the one it had was lossy: it
16
+ # stripped punctuation, so "Yes!" produced the id "Yes", which was
17
+ # another choice's label exactly.
18
+ class ChoiceMapper
19
+ ID_KEY = "messenger.choice_mapping"
20
+ POSITION_KEY = "messenger.position_mapping"
21
+
22
+ def initialize(app)
23
+ @app = app
24
+ FlowChat.logger.debug { "#{self.class.name}: Initialized" }
25
+ end
26
+
27
+ def call(context)
28
+ @context = context
29
+ @session = context.session
30
+
31
+ handle_choice_input if intercept?
32
+
33
+ # The maps belong to exactly one screen: this turn's, if it had a
34
+ # resolvable answer, or one that already fell out of use otherwise.
35
+ # Either way nothing here is still owed to the next screen, so they
36
+ # are cleared unconditionally rather than asked whether they still
37
+ # look "live" - create_mappings immediately below repopulates them
38
+ # whenever the app actually returns choices.
39
+ #
40
+ # An earlier version asked stale_mappings? that question after
41
+ # handle_choice_input had already rewritten @context.input to the
42
+ # *resolved* value, which can equal one of the map's own keys (an
43
+ # Array choice's key is its label, and the wire value is the title
44
+ # built from that label), so the check answered "still live"
45
+ # about a value that was never a fresh reply. That let the maps
46
+ # survive into a free-text screen and reinterpret a typed answer
47
+ # there as the previous menu's choice. This was fixed once for
48
+ # WhatsApp in Task 6 and once here in Task 13; both fixes had the
49
+ # same shape and the same blind spot, which is why the guard is
50
+ # gone rather than patched a third time.
51
+ clear_mappings
52
+
53
+ type, prompt, choices, media = @app.call(context)
54
+
55
+ choices = create_mappings(choices) if choices.present?
56
+
57
+ [type, prompt, choices, media]
58
+ end
59
+
60
+ private
61
+
62
+ def platform_limits
63
+ FlowChat::Config.messenger
64
+ end
65
+
66
+ def always_number?
67
+ false
68
+ end
69
+
70
+ def id_key
71
+ self.class::ID_KEY
72
+ end
73
+
74
+ def position_key
75
+ self.class::POSITION_KEY
76
+ end
77
+
78
+ def get_id_mapping
79
+ @session.get(id_key) || {}
80
+ end
81
+
82
+ def get_position_mapping
83
+ @session.get(position_key) || {}
84
+ end
85
+
86
+ # Titles first, then positions. A tap sends the title as its payload
87
+ # and a user typing what they read sends the same string, so both
88
+ # land on the same entry. A position must lose to a title, because a
89
+ # choice labelled "1" would otherwise be unreachable: its title is
90
+ # "1", and a bare digit is only a position when the screen was
91
+ # numbered at all.
92
+ def resolved_choice
93
+ input = @context.input.to_s
94
+ return nil if input.empty?
95
+
96
+ get_id_mapping[input] || get_position_mapping[input]
97
+ end
98
+
99
+ def intercept?
100
+ @context.input.present? && resolved_choice.present?
101
+ end
102
+
103
+ def handle_choice_input
104
+ original = resolved_choice
105
+ FlowChat.logger.info { "#{self.class.name}: Resolving input #{@context.input} to #{original}" }
106
+ @context.input = original
107
+ end
108
+
109
+ def clear_mappings
110
+ @session.delete(id_key)
111
+ @session.delete(position_key)
112
+ end
113
+
114
+ def create_mappings(choices)
115
+ cap = display_title_cap(choices.length)
116
+ return passthrough_mapping(choices) if cap.nil?
117
+
118
+ title_choices = {}
119
+ id_mapping = {}
120
+
121
+ FlowChat::ChoiceTitles.build(choices, cap).each do |key, label, title, _truncated|
122
+ title_choices[title] = label
123
+ id_mapping[title] = key
124
+ end
125
+
126
+ @session.set(id_key, id_mapping)
127
+
128
+ if number_choices?(choices)
129
+ @session.set(position_key, choices.keys.map.with_index(1) { |key, i| [i.to_s, key.to_s] }.to_h)
130
+ else
131
+ @session.delete(position_key)
132
+ end
133
+
134
+ title_choices
135
+ end
136
+
137
+ # On the :numbered rung the body already prints each full label beside
138
+ # its number, so the label itself is what is on screen and stays
139
+ # resolvable - nothing is truncated, so there is no shortened form to
140
+ # key on instead.
141
+ #
142
+ # A label shared by two choices is dropped rather than resolved to the
143
+ # first of them. It identifies neither on a screen that shows both, and
144
+ # the number printed next to each is the reply that does.
145
+ def passthrough_mapping(choices)
146
+ labels = choices.map { |key, label| [label.to_s, key.to_s] }
147
+ repeated = labels.map(&:first).tally
148
+
149
+ @session.set(id_key, labels.reject { |label, _| repeated[label] > 1 }.to_h)
150
+ @session.set(position_key, choices.keys.map.with_index(1) { |key, i| [i.to_s, key.to_s] }.to_h)
151
+ choices
152
+ end
153
+
154
+ # The title cap the renderer will use for these choices, or nil on
155
+ # the :none and :numbered rungs, where there is no separate title to
156
+ # alias: :none has no choices, and :numbered already lists each full
157
+ # label next to its number straight in the body, with nothing more
158
+ # to truncate. This calls the same FlowChat::Meta::ChoiceLadder the
159
+ # renderer consults, so the two cannot drift on which rung a given
160
+ # count lands on.
161
+ def display_title_cap(count)
162
+ case FlowChat::Meta::ChoiceLadder.rung_for(count, platform_limits)
163
+ when :quick_replies then platform_limits.max_quick_reply_title
164
+ when :carousel then platform_limits.max_button_title
165
+ end
166
+ end
167
+
168
+ # A position number is only worth resolving when one is genuinely on
169
+ # screen: on the :numbered rung, or wherever always_number? forces
170
+ # the renderer's #body to list one regardless of rung (Instagram,
171
+ # for a desktop user with no tappable surface at all), or on a
172
+ # quick-reply/carousel rung whose titles FlowChat::ChoiceTitles
173
+ # decided were ambiguous and prefixed with a number. always_number?
174
+ # only ever governs that body listing; it says nothing about whether
175
+ # a title itself was numbered, so it cannot answer this alone.
176
+ def number_choices?(choices)
177
+ count = choices.length
178
+ return true if FlowChat::Meta::ChoiceLadder.numbers_in_body?(count, platform_limits, always_number: always_number?)
179
+
180
+ FlowChat::ChoiceTitles.ambiguous?(choices, display_title_cap(count))
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end