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,972 @@
1
+ # Unified Choice Resolution Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Make every choice mapper decide ambiguity under the same equivalence relation its resolver matches on, so a user who types what they see always lands on the choice they read.
6
+
7
+ **Architecture:** One shared core (`FlowChat::TextTruncator` + `FlowChat::ChoiceTitles`) gains two parameters: a **measure** (characters or bytes) and a **fold** (the normalization the resolver applies before matching). `ambiguity_reason` decides duplication under the fold, and numbers the whole set when two choices collapse under it. The wire value a platform sends back becomes the **displayed title** rather than a separately generated id, which collapses the id map and the alias map into one map and removes `IdGenerator` entirely. USSD is exempt: it resolves on position, which is injective by construction.
8
+
9
+ **Tech Stack:** Ruby, Minitest, StandardRB.
10
+
11
+ **User Verification:** NO — no user verification required. Every task is verifiable by the test suite.
12
+
13
+ ---
14
+
15
+ ## Background: the single root cause
16
+
17
+ Every choice-resolution bug found in review is the same defect — **the ambiguity check and the resolver use different equivalence relations**:
18
+
19
+ | Mapper | Resolver matches on | Ambiguity checked under | Result |
20
+ |---|---|---|---|
21
+ | WhatsApp / Messenger / Instagram | `IdGenerator#normalize_label` (punctuation stripped) | raw titles | `{a: "Yes!", b: "Yes"}` → typing `Yes` hits **A** while button **B** reads `Yes` |
22
+ | HTTP | `label.strip.downcase` | nothing | `||=` first-wins silently drops the second duplicate |
23
+ | Intercom | `label.strip.downcase` | nothing | same first-wins drop |
24
+ | Telegram | first 64 **characters** of the key, against a 64-**byte** field | nothing | multibyte key overflows and is rejected; shared prefixes collide and the choice becomes unselectable |
25
+ | USSD | position | positions are unique by construction | **no bug** |
26
+
27
+ USSD is safe because its equivalence relation *is* its uniqueness guarantee. That is the invariant to hold the others to.
28
+
29
+ **Why numbering is the universal disambiguator:** a position prefix puts the distinguishing token at the *front*, so it survives truncation from the right — including Telegram's 64-byte cut. A hash *suffix* is the one disambiguator a truncating platform can slice off.
30
+
31
+ ### Platform constraints (verified against vendor docs)
32
+
33
+ | Platform | Field | Limit | Character restrictions |
34
+ |---|---|---|---|
35
+ | WhatsApp | button reply `id` | 256 chars | none documented |
36
+ | WhatsApp | list row `id` | **200 chars** | none documented |
37
+ | Messenger / Instagram | quick reply `payload` | 1000 chars | none documented |
38
+ | Telegram | `callback_data` | 1–64 **bytes** | none — typed as `bytes` |
39
+
40
+ No platform restricts the character set. `IdGenerator#normalize_label`'s strip is therefore unjustified, and it is the direct cause of the Meta bug. WhatsApp additionally documents button `title` as *"Must be unique if using multiple buttons"* — the uniqueness `ChoiceTitles.build` guarantees is a platform requirement, not just our convention.
41
+
42
+ Sources: [WhatsApp reply buttons](https://developers.facebook.com/docs/whatsapp/cloud-api/messages/interactive-reply-buttons-messages/), [WhatsApp lists](https://developers.facebook.com/docs/whatsapp/cloud-api/messages/interactive-list-messages/), [Messenger quick replies](https://developers.facebook.com/docs/messenger-platform/send-messages/quick-replies/), [Telegram bot buttons](https://core.telegram.org/api/bots/buttons).
43
+
44
+ ### File structure
45
+
46
+ | File | Responsibility after this plan |
47
+ |---|---|
48
+ | `lib/flow_chat/text_truncator.rb` | Truncate and number a string under a **measure** (characters or bytes) |
49
+ | `lib/flow_chat/choice_titles.rb` | Decide ambiguity under a **fold**, and produce distinct displayed titles |
50
+ | `lib/flow_chat/id_generator.rb` | **Deleted** |
51
+ | `lib/flow_chat/whatsapp/middleware/choice_mapper.rb` | Title-as-wire-value, two maps (title, position) |
52
+ | `lib/flow_chat/messenger/middleware/choice_mapper.rb` | Same; Instagram inherits |
53
+ | `lib/flow_chat/http/middleware/choice_mapper.rb` | Adopts fold + numbering |
54
+ | `lib/flow_chat/intercom/middleware/choice_mapper.rb` | Adopts fold + numbering |
55
+ | `lib/flow_chat/telegram/middleware/choice_mapper.rb` | Adopts core with byte measure; gains real maps |
56
+ | `lib/flow_chat/ussd/middleware/choice_mapper.rb` | Unchanged — exemption pinned by a test |
57
+
58
+ ---
59
+
60
+ ### Task 1: Give TextTruncator a pluggable measure
61
+
62
+ **Goal:** `TextTruncator` can cut to a byte budget on character boundaries, so Telegram's 64-byte field is expressible without a Telegram-specific branch.
63
+
64
+ **Files:**
65
+ - Modify: `lib/flow_chat/text_truncator.rb:20-42`
66
+ - Test: `test/unit/text_truncator_test.rb`
67
+
68
+ **Acceptance Criteria:**
69
+ - [ ] `truncate(text, cap, measure: :bytes)` never returns a string whose `bytesize` exceeds `cap`
70
+ - [ ] Byte truncation never splits a multi-byte character (result is always valid UTF-8)
71
+ - [ ] The `"..."` ellipsis is charged in the active measure
72
+ - [ ] `number(text, position, cap, measure:)` charges the `"N. "` prefix in the active measure
73
+ - [ ] Default measure stays `:characters`; every existing call site is unchanged in behaviour
74
+
75
+ **Verify:** `bundle exec ruby -Itest test/unit/text_truncator_test.rb` → 0 failures
76
+
77
+ **Steps:**
78
+
79
+ - [ ] **Step 1: Write the failing tests**
80
+
81
+ ```ruby
82
+ # test/unit/text_truncator_test.rb — append inside the existing class
83
+ def test_byte_measure_never_exceeds_the_cap
84
+ text = "日本語のテキストです" # 3 bytes per character
85
+ result = FlowChat::TextTruncator.truncate(text, 12, measure: :bytes)
86
+
87
+ assert_operator result.bytesize, :<=, 12
88
+ assert result.valid_encoding?, "byte truncation must not split a character"
89
+ end
90
+
91
+ def test_byte_measure_leaves_short_multibyte_text_alone
92
+ assert_equal "日本", FlowChat::TextTruncator.truncate("日本", 6, measure: :bytes)
93
+ end
94
+
95
+ def test_character_measure_is_the_default_and_unchanged
96
+ assert_equal "abc...", FlowChat::TextTruncator.truncate("abcdefghij", 6)
97
+ end
98
+
99
+ def test_number_charges_the_prefix_in_bytes
100
+ result = FlowChat::TextTruncator.number("日本語のテキスト", 1, 12, measure: :bytes)
101
+
102
+ assert result.start_with?("1. ")
103
+ assert_operator result.bytesize, :<=, 12
104
+ assert result.valid_encoding?
105
+ end
106
+ ```
107
+
108
+ - [ ] **Step 2: Run to verify they fail**
109
+
110
+ Run: `bundle exec ruby -Itest test/unit/text_truncator_test.rb`
111
+ Expected: FAIL — `unknown keyword: :measure`
112
+
113
+ - [ ] **Step 3: Implement the measure**
114
+
115
+ ```ruby
116
+ # lib/flow_chat/text_truncator.rb — replace .truncate and .number
117
+ ELLIPSIS = "..."
118
+
119
+ # Truncates text to a cap expressed in `measure` units.
120
+ #
121
+ # :bytes exists because Meta and Telegram both size their fields in
122
+ # bytes, not characters - FlowChat::Instagram::Client#measure already
123
+ # makes the same distinction for message bodies, with the same reason:
124
+ # a character count lets multibyte text through to be rejected.
125
+ #
126
+ # Byte truncation walks characters rather than slicing bytes, because
127
+ # byteslice can cut a multi-byte sequence in half and produce a string
128
+ # that is no longer valid UTF-8.
129
+ def self.truncate(text, length, measure: :characters)
130
+ text = text.to_s
131
+ length = 0 if length.negative?
132
+ return text if size_of(text, measure) <= length
133
+ return cut(text, length, measure) if length < size_of(ELLIPSIS, measure)
134
+
135
+ cut(text, length - size_of(ELLIPSIS, measure), measure) + ELLIPSIS
136
+ end
137
+
138
+ def self.number(text, position, cap, measure: :characters)
139
+ prefix = "#{position}. "
140
+ prefix + truncate(text.to_s, cap - size_of(prefix, measure), measure: measure)
141
+ end
142
+
143
+ def self.size_of(string, measure)
144
+ (measure == :bytes) ? string.bytesize : string.length
145
+ end
146
+ private_class_method :size_of
147
+
148
+ # Takes whole characters while they still fit the budget, so the result
149
+ # is always valid UTF-8 whichever measure is in force.
150
+ def self.cut(string, budget, measure)
151
+ return "" if budget <= 0
152
+ return string[0, budget] if measure != :bytes
153
+
154
+ taken = +""
155
+ string.each_char do |char|
156
+ break if taken.bytesize + char.bytesize > budget
157
+ taken << char
158
+ end
159
+ taken
160
+ end
161
+ private_class_method :cut
162
+ ```
163
+
164
+ - [ ] **Step 4: Run the tests**
165
+
166
+ Run: `bundle exec ruby -Itest test/unit/text_truncator_test.rb`
167
+ Expected: PASS
168
+
169
+ - [ ] **Step 5: Run the full suite to prove no call site changed behaviour**
170
+
171
+ Run: `bundle exec rake test`
172
+ Expected: 0 failures
173
+
174
+ - [ ] **Step 6: Commit**
175
+
176
+ ```bash
177
+ git add lib/flow_chat/text_truncator.rb test/unit/text_truncator_test.rb
178
+ git commit -m "feat(truncator): size text in bytes where a platform does"
179
+ ```
180
+
181
+ ---
182
+
183
+ ### Task 2: Make ChoiceTitles decide ambiguity under the resolver's fold
184
+
185
+ **Goal:** `ambiguity_reason` treats two titles as duplicates when they collapse under the fold the resolver will later match on, so numbering fires exactly when the resolver would otherwise be unable to tell two choices apart.
186
+
187
+ **Files:**
188
+ - Modify: `lib/flow_chat/choice_titles.rb:38-113`
189
+ - Test: `test/unit/choice_titles_test.rb`
190
+
191
+ **Acceptance Criteria:**
192
+ - [ ] `build`, `ambiguous?` and `ambiguity_reason` accept `fold:` (default identity) and `measure:` (default `:characters`)
193
+ - [ ] Two titles equal after `fold` are duplicates, so the set is numbered
194
+ - [ ] `{a: "Yes", b: "YES"}` with a downcasing fold is numbered; with the identity fold it is not
195
+ - [ ] Titles are distinct **under the fold** for every ambiguity class
196
+ - [ ] `aliases_for` is deleted — its callers stop existing in Task 3
197
+
198
+ **Verify:** `bundle exec ruby -Itest test/unit/choice_titles_test.rb` → 0 failures
199
+
200
+ **Steps:**
201
+
202
+ - [ ] **Step 1: Write the failing tests**
203
+
204
+ ```ruby
205
+ # test/unit/choice_titles_test.rb — append inside the existing class
206
+ DOWNCASE = ->(s) { s.strip.downcase }
207
+
208
+ def test_titles_differing_only_by_case_are_ambiguous_under_a_downcasing_fold
209
+ choices = {"a" => "Yes", "b" => "YES"}
210
+
211
+ assert FlowChat::ChoiceTitles.ambiguous?(choices, 20, fold: DOWNCASE)
212
+ refute FlowChat::ChoiceTitles.ambiguous?(choices, 20)
213
+ end
214
+
215
+ def test_numbering_makes_titles_distinct_under_the_fold
216
+ choices = {"a" => "Yes", "b" => "YES"}
217
+ titles = FlowChat::ChoiceTitles.build(choices, 20, fold: DOWNCASE).map { |_k, _l, t, _tr| t }
218
+
219
+ assert_equal ["1. Yes", "2. YES"], titles
220
+ assert_equal titles.map { |t| DOWNCASE.call(t) }.uniq.length, titles.length
221
+ end
222
+
223
+ # The Meta bug: the resolver stripped punctuation, the check did not.
224
+ def test_titles_differing_only_by_punctuation_are_ambiguous_under_a_stripping_fold
225
+ strip = ->(s) { s.gsub(/[^\w\s]/, "").strip }
226
+ choices = {"a" => "Yes!", "b" => "Yes"}
227
+
228
+ assert FlowChat::ChoiceTitles.ambiguous?(choices, 20, fold: strip)
229
+ end
230
+
231
+ def test_byte_measure_numbers_a_set_that_collides_only_after_byte_truncation
232
+ choices = {"a" => "日本語のテキストです one", "b" => "日本語のテキストです two"}
233
+
234
+ assert FlowChat::ChoiceTitles.ambiguous?(choices, 30, measure: :bytes)
235
+ end
236
+ ```
237
+
238
+ - [ ] **Step 2: Run to verify they fail**
239
+
240
+ Run: `bundle exec ruby -Itest test/unit/choice_titles_test.rb`
241
+ Expected: FAIL — `unknown keyword: :fold`
242
+
243
+ - [ ] **Step 3: Thread fold and measure through**
244
+
245
+ ```ruby
246
+ # lib/flow_chat/choice_titles.rb — replace .build, .ambiguous?, .ambiguity_reason
247
+ IDENTITY = ->(string) { string }
248
+
249
+ # @param fold [Proc] the normalization the resolver applies before
250
+ # matching input. Two titles that fold to the same string cannot be
251
+ # told apart by that resolver, so the set is ambiguous and gets
252
+ # numbered - the same rule USSD gets for free by resolving on
253
+ # position, which is injective by construction.
254
+ def self.build(choices, cap, fold: IDENTITY, measure: :characters)
255
+ reason = ambiguity_reason(choices, cap, fold: fold, measure: measure)
256
+ prefixed = !reason.nil?
257
+
258
+ if prefixed
259
+ FlowChat.logger.debug { "#{name}: numbering choices, titles are ambiguous (#{reason})" }
260
+ end
261
+
262
+ choices.map.with_index(1) do |(key, label), position|
263
+ label = label.to_s
264
+
265
+ if prefixed
266
+ title = FlowChat::TextTruncator.number(label, position, cap, measure: measure)
267
+ else
268
+ title = FlowChat::TextTruncator.truncate(label, cap, measure: measure)
269
+ end
270
+
271
+ [key.to_s, label, title, title != label]
272
+ end
273
+ end
274
+
275
+ def self.ambiguous?(choices, cap, fold: IDENTITY, measure: :characters)
276
+ !ambiguity_reason(choices, cap, fold: fold, measure: measure).nil?
277
+ end
278
+
279
+ def self.ambiguity_reason(choices, cap, fold: IDENTITY, measure: :characters)
280
+ labels = choices.map { |_, label| label.to_s }
281
+ titles = labels.map { |label| FlowChat::TextTruncator.truncate(label, cap, measure: measure) }
282
+
283
+ truncated_labels = labels.zip(titles).select { |label, title| title != label }.map(&:first)
284
+ duplicate_titles = titles.map { |title| fold.call(title) }.tally.select { |_, count| count > 1 }.keys
285
+
286
+ return nil if truncated_labels.empty? && duplicate_titles.empty?
287
+
288
+ parts = []
289
+ parts << "truncated: #{truncated_labels.inspect}" unless truncated_labels.empty?
290
+ parts << "duplicate titles: #{duplicate_titles.inspect}" unless duplicate_titles.empty?
291
+ parts.join(", ")
292
+ end
293
+ ```
294
+
295
+ - [ ] **Step 4: Delete `aliases_for` and its docs**
296
+
297
+ Remove `lib/flow_chat/choice_titles.rb:83-113` entirely. It has no callers after Task 3, and the guarantee it leaned on ("`.build` never hands back two identical titles") is now the guarantee the wire value itself rests on.
298
+
299
+ - [ ] **Step 5: Run the tests**
300
+
301
+ Run: `bundle exec ruby -Itest test/unit/choice_titles_test.rb`
302
+ Expected: PASS (existing `aliases_for` tests deleted alongside the method)
303
+
304
+ - [ ] **Step 6: Commit**
305
+
306
+ ```bash
307
+ git add lib/flow_chat/choice_titles.rb test/unit/choice_titles_test.rb
308
+ git commit -m "feat(choices): decide ambiguity under the resolver's own equivalence"
309
+ ```
310
+
311
+ ---
312
+
313
+ ### Task 3: Meta mappers send the displayed title, not a generated id
314
+
315
+ **Goal:** WhatsApp, Messenger and Instagram put the displayed title on the wire, so the id map and the alias map become one map and typing what is on a button always resolves to that button.
316
+
317
+ **Files:**
318
+ - Modify: `lib/flow_chat/whatsapp/middleware/choice_mapper.rb:99-236`
319
+ - Modify: `lib/flow_chat/messenger/middleware/choice_mapper.rb:93-160`
320
+ - Test: `test/unit/whatsapp/middleware/choice_mapper_test.rb`
321
+ - Test: `test/unit/messenger/middleware/choice_mapper_test.rb`
322
+ - Test: `test/unit/instagram/middleware/choice_mapper_test.rb`
323
+
324
+ **Acceptance Criteria:**
325
+ - [ ] The choices handed to the renderer are keyed by displayed title
326
+ - [ ] `whatsapp.alias_mapping`, `messenger.alias_mapping` and `instagram.alias_mapping` are gone — set, read, cleared and all
327
+ - [ ] Resolution order is title → position
328
+ - [ ] `{a: "Yes!", b: "Yes"}`: typing `Yes` resolves to **b**, typing `Yes!` resolves to **a**
329
+ - [ ] `{a: "Savings", b: "Savings"}`: titles are `1. Savings` / `2. Savings`, both resolvable and distinct
330
+ - [ ] No generated id ever exceeds the platform's id cap (256 button / 200 list row / 1000 payload)
331
+
332
+ **Verify:** `bundle exec ruby -Itest test/unit/whatsapp/middleware/choice_mapper_test.rb && bundle exec ruby -Itest test/unit/messenger/middleware/choice_mapper_test.rb && bundle exec ruby -Itest test/unit/instagram/middleware/choice_mapper_test.rb` → 0 failures
333
+
334
+ **Steps:**
335
+
336
+ - [ ] **Step 1: Write the failing regression test**
337
+
338
+ ```ruby
339
+ # test/unit/whatsapp/middleware/choice_mapper_test.rb — append inside the existing class
340
+ # The review bug: IdGenerator stripped "!" from "Yes!", so choice A's id was
341
+ # choice B's label verbatim, and the id map was consulted before the alias
342
+ # map. A user typing what was printed on button B landed on A.
343
+ def test_typing_a_title_resolves_to_the_choice_that_shows_it
344
+ mapper = build_mapper(choices: {"a" => "Yes!", "b" => "Yes"})
345
+
346
+ assert_equal "b", resolve(mapper, "Yes")
347
+ assert_equal "a", resolve(mapper, "Yes!")
348
+ end
349
+
350
+ def test_duplicate_labels_are_numbered_and_both_resolve
351
+ mapper = build_mapper(choices: {"a" => "Savings", "b" => "Savings"})
352
+
353
+ assert_equal ["1. Savings", "2. Savings"], rendered_titles(mapper)
354
+ assert_equal "a", resolve(mapper, "1. Savings")
355
+ assert_equal "b", resolve(mapper, "2. Savings")
356
+ assert_equal "b", resolve(mapper, "2")
357
+ end
358
+
359
+ def test_no_alias_mapping_is_written
360
+ mapper = build_mapper(choices: {"a" => "Yes", "b" => "No"})
361
+
362
+ assert_nil mapper.session.get("whatsapp.alias_mapping")
363
+ end
364
+
365
+ def test_generated_ids_never_exceed_the_list_row_cap
366
+ choices = (1..12).to_h { |i| ["k#{i}", "#{"long label " * 30}#{i}"] }
367
+ mapper = build_mapper(choices: choices)
368
+
369
+ rendered_titles(mapper).each do |title|
370
+ assert_operator title.length, :<=, 200, "list row id cap is 200 characters"
371
+ end
372
+ end
373
+ ```
374
+
375
+ - [ ] **Step 2: Run to verify they fail**
376
+
377
+ Run: `bundle exec ruby -Itest test/unit/whatsapp/middleware/choice_mapper_test.rb`
378
+ Expected: FAIL — typing `Yes` resolves to `"a"`
379
+
380
+ - [ ] **Step 3: Replace WhatsApp's mapping construction**
381
+
382
+ ```ruby
383
+ # lib/flow_chat/whatsapp/middleware/choice_mapper.rb — replace create_id_mapping
384
+ # The wire value IS the displayed title. FlowChat::ChoiceTitles
385
+ # guarantees the titles in a set are distinct - numbering the whole
386
+ # set when they would not be - so the title needs no separate id
387
+ # space to be unique in, and there is nothing for a generated id to
388
+ # collide with. Titles are bounded by the rung's title cap (20 or
389
+ # 24), comfortably inside WhatsApp's id caps (256 button, 200 row).
390
+ def create_id_mapping(choices)
391
+ cap = display_title_cap(choices.length)
392
+ return passthrough_mapping(choices) if cap.nil?
393
+
394
+ title_choices = {}
395
+ choice_mapping = {}
396
+
397
+ FlowChat::ChoiceTitles.build(choices, cap).each do |key, label, title, _truncated|
398
+ title_choices[title] = label
399
+ choice_mapping[title] = key
400
+ end
401
+
402
+ store_choice_mapping(choice_mapping)
403
+
404
+ if number_choices?(choices)
405
+ store_position_mapping(choices.keys.map.with_index(1) { |key, i| [i.to_s, key.to_s] }.to_h)
406
+ end
407
+
408
+ title_choices
409
+ end
410
+
411
+ # Above the row cap the renderer numbers the body and shows each full
412
+ # label, so there is no title to key on and a typed number is the
413
+ # only reply that means anything.
414
+ def passthrough_mapping(choices)
415
+ store_position_mapping(choices.keys.map.with_index(1) { |key, i| [i.to_s, key.to_s] }.to_h)
416
+ choices
417
+ end
418
+ ```
419
+
420
+ - [ ] **Step 4: Collapse the resolution order**
421
+
422
+ ```ruby
423
+ # lib/flow_chat/whatsapp/middleware/choice_mapper.rb — replace resolved_choice
424
+ # Titles first, then positions. A tap sends the title as its payload
425
+ # and a user who types what they read sends the same string, so both
426
+ # arrive at the same map entry. A position is the fallback, and only
427
+ # means anything when a number is genuinely on screen.
428
+ def resolved_choice
429
+ input = @context.input.to_s
430
+ get_choice_mapping[input] || get_position_mapping[input]
431
+ end
432
+ ```
433
+
434
+ Delete `store_alias_mapping`, `get_alias_mapping`, `clear_alias_mapping`, and the `clear_alias_mapping` call inside `clear_choice_state`.
435
+
436
+ - [ ] **Step 5: Apply the same change to Messenger**
437
+
438
+ ```ruby
439
+ # lib/flow_chat/messenger/middleware/choice_mapper.rb — replace create_mappings
440
+ def create_mappings(choices)
441
+ cap = display_title_cap(choices.length)
442
+ return passthrough_mapping(choices) if cap.nil?
443
+
444
+ title_choices = {}
445
+ id_mapping = {}
446
+
447
+ FlowChat::ChoiceTitles.build(choices, cap).each do |key, label, title, _truncated|
448
+ title_choices[title] = label
449
+ id_mapping[title] = key
450
+ end
451
+
452
+ @session.set(id_key, id_mapping)
453
+
454
+ if number_choices?(choices)
455
+ @session.set(position_key, choices.keys.map.with_index(1) { |key, i| [i.to_s, key.to_s] }.to_h)
456
+ else
457
+ @session.delete(position_key)
458
+ end
459
+
460
+ title_choices
461
+ end
462
+
463
+ def passthrough_mapping(choices)
464
+ @session.set(position_key, choices.keys.map.with_index(1) { |key, i| [i.to_s, key.to_s] }.to_h)
465
+ choices
466
+ end
467
+ ```
468
+
469
+ ```ruby
470
+ # lib/flow_chat/messenger/middleware/choice_mapper.rb — replace resolved_choice
471
+ def resolved_choice
472
+ input = @context.input.to_s
473
+ return nil if input.empty?
474
+
475
+ get_id_mapping[input] || get_position_mapping[input]
476
+ end
477
+ ```
478
+
479
+ Delete `ALIAS_KEY`, `alias_key`, `get_alias_mapping` and its `clear_mappings` line. Instagram's subclass (`lib/flow_chat/instagram/middleware/choice_mapper.rb:6`) declares its own `ALIAS_KEY` — delete that constant too.
480
+
481
+ - [ ] **Step 6: Rewrite the three stale precedence comments**
482
+
483
+ The block at `whatsapp/middleware/choice_mapper.rb:99-107`, the one at `messenger/middleware/choice_mapper.rb:7-15`, and the one at `messenger/middleware/choice_mapper.rb:93-101` all describe an id/alias/position ordering that no longer exists, and all three state the invariant one word too narrow ("never registers an alias equal to **its own** choice's generated id"). Replace each with the two-map explanation from Step 4.
484
+
485
+ - [ ] **Step 7: Run the tests**
486
+
487
+ Run: `bundle exec ruby -Itest test/unit/whatsapp/middleware/choice_mapper_test.rb && bundle exec ruby -Itest test/unit/messenger/middleware/choice_mapper_test.rb && bundle exec ruby -Itest test/unit/instagram/middleware/choice_mapper_test.rb`
488
+ Expected: PASS
489
+
490
+ - [ ] **Step 8: Commit**
491
+
492
+ ```bash
493
+ git add lib/flow_chat/whatsapp lib/flow_chat/messenger lib/flow_chat/instagram test/unit/whatsapp test/unit/messenger test/unit/instagram
494
+ git commit -m "fix(choices): send the title users read, not a lossy copy of it"
495
+ ```
496
+
497
+ ---
498
+
499
+ ### Task 4: Delete IdGenerator
500
+
501
+ **Goal:** Remove the class whose lossy normalization caused the bug, now that nothing calls it.
502
+
503
+ **Files:**
504
+ - Delete: `lib/flow_chat/id_generator.rb`
505
+ - Delete: `test/unit/id_generator_test.rb`
506
+ - Modify: `lib/flow_chat.rb` (remove the require, if one exists)
507
+
508
+ **Acceptance Criteria:**
509
+ - [ ] `grep -rn "IdGenerator" lib/ test/` returns nothing
510
+ - [ ] Full suite passes
511
+
512
+ **Verify:** `grep -rn "IdGenerator" lib/ test/ ; bundle exec rake test` → no matches, 0 failures
513
+
514
+ **Steps:**
515
+
516
+ - [ ] **Step 1: Confirm there are no remaining callers**
517
+
518
+ Run: `grep -rn "IdGenerator" lib/ test/`
519
+ Expected: no output. If anything remains, it belongs to Task 3 and must be finished first.
520
+
521
+ - [ ] **Step 2: Delete the files**
522
+
523
+ ```bash
524
+ git rm lib/flow_chat/id_generator.rb test/unit/id_generator_test.rb
525
+ ```
526
+
527
+ - [ ] **Step 3: Drop the require**
528
+
529
+ Run: `grep -n "id_generator" lib/flow_chat.rb` and delete the matching line if present.
530
+
531
+ - [ ] **Step 4: Run the full suite**
532
+
533
+ Run: `bundle exec rake test`
534
+ Expected: 0 failures
535
+
536
+ - [ ] **Step 5: Commit**
537
+
538
+ ```bash
539
+ git commit -am "refactor(choices): drop the id generator the titles replaced"
540
+ ```
541
+
542
+ ---
543
+
544
+ ### Task 5: HTTP mapper adopts the fold
545
+
546
+ **Goal:** HTTP stops silently dropping the second of two labels that match case-insensitively.
547
+
548
+ **Files:**
549
+ - Modify: `lib/flow_chat/http/middleware/choice_mapper.rb:36-66`
550
+ - Test: `test/unit/http/middleware/choice_mapper_test.rb`
551
+
552
+ **Acceptance Criteria:**
553
+ - [ ] Labels that fold to the same string cause the set to be numbered
554
+ - [ ] `{a: "Savings", b: "Savings"}` → both resolvable, neither dropped
555
+ - [ ] `{a: "Yes", b: "YES"}` → both resolvable
556
+ - [ ] A key still resolves by passing through unmapped, as today
557
+ - [ ] No `||=` remains in `remember`
558
+
559
+ **Verify:** `bundle exec ruby -Itest test/unit/http/middleware/choice_mapper_test.rb` → 0 failures
560
+
561
+ **Note:** this changes what a web client displays for a colliding set — it will receive `1. Savings` / `2. Savings` rather than two identical labels. That is the point: two identical labels are indistinguishable to whoever is reading them.
562
+
563
+ **Steps:**
564
+
565
+ - [ ] **Step 1: Write the failing test**
566
+
567
+ ```ruby
568
+ # test/unit/http/middleware/choice_mapper_test.rb — append inside the existing class
569
+ def test_duplicate_labels_are_numbered_rather_than_dropped
570
+ mapper = build_mapper(choices: {"a" => "Savings", "b" => "Savings"})
571
+
572
+ assert_equal "a", resolve(mapper, "1. Savings")
573
+ assert_equal "b", resolve(mapper, "2. Savings")
574
+ end
575
+
576
+ def test_labels_differing_only_by_case_both_resolve
577
+ mapper = build_mapper(choices: {"a" => "Yes", "b" => "YES"})
578
+
579
+ assert_equal "a", resolve(mapper, "1. Yes")
580
+ assert_equal "b", resolve(mapper, "2. YES")
581
+ end
582
+ ```
583
+
584
+ - [ ] **Step 2: Run to verify it fails**
585
+
586
+ Run: `bundle exec ruby -Itest test/unit/http/middleware/choice_mapper_test.rb`
587
+ Expected: FAIL — both inputs resolve to `"a"`, or neither resolves
588
+
589
+ - [ ] **Step 3: Fold, number, and drop the first-wins**
590
+
591
+ ```ruby
592
+ # lib/flow_chat/http/middleware/choice_mapper.rb — replace remember
593
+ FOLD = ->(string) { string.strip.downcase }
594
+
595
+ # A client renders these itself, so the cap is the flow's own labels
596
+ # rather than a platform limit - nothing is truncated here. What the
597
+ # fold buys is duplicate detection: two labels a visitor cannot tell
598
+ # apart are numbered, rather than the second silently losing to the
599
+ # first as it did under `||=`.
600
+ def remember(context, choices)
601
+ if choices.blank?
602
+ context.session.delete(SESSION_KEY)
603
+ return
604
+ end
605
+
606
+ mapping = {}
607
+ FlowChat::ChoiceTitles.build(choices, UNCAPPED, fold: FOLD).each do |key, _label, title, _truncated|
608
+ mapping[FOLD.call(title)] = key
609
+ end
610
+
611
+ context.session.set(SESSION_KEY, mapping)
612
+ FlowChat.logger.debug { "Http::ChoiceMapper: Created mapping: #{mapping}" }
613
+ end
614
+ ```
615
+
616
+ Add `UNCAPPED = Float::INFINITY` beside `SESSION_KEY`, and make `remember` return the numbered choices so the client renders what the mapper resolves — mirroring how the Meta mappers hand back their transformed set. Wire that through `call`:
617
+
618
+ ```ruby
619
+ def call(context)
620
+ resolve_input(context)
621
+
622
+ type, prompt, choices, media = @app.call(context)
623
+
624
+ choices = remember(context, choices)
625
+
626
+ [type, prompt, choices, media]
627
+ end
628
+ ```
629
+
630
+ - [ ] **Step 4: Run the tests**
631
+
632
+ Run: `bundle exec ruby -Itest test/unit/http/middleware/choice_mapper_test.rb`
633
+ Expected: PASS
634
+
635
+ - [ ] **Step 5: Commit**
636
+
637
+ ```bash
638
+ git add lib/flow_chat/http test/unit/http
639
+ git commit -m "fix(http): stop the second of two matching labels losing silently"
640
+ ```
641
+
642
+ ---
643
+
644
+ ### Task 6: Intercom mapper adopts the fold
645
+
646
+ **Goal:** Same fix for Intercom, which carries the identical `||=` first-wins drop.
647
+
648
+ **Files:**
649
+ - Modify: `lib/flow_chat/intercom/middleware/choice_mapper.rb:57-75`
650
+ - Test: `test/unit/intercom/middleware/choice_mapper_test.rb`
651
+
652
+ **Acceptance Criteria:**
653
+ - [ ] Duplicate labels both resolve; neither is dropped
654
+ - [ ] A bare position number still resolves, as today
655
+ - [ ] No `||=` remains in `remember`
656
+ - [ ] The comment claiming "the first wins, as it does on the screen" is gone — it no longer does
657
+
658
+ **Verify:** `bundle exec ruby -Itest test/unit/intercom/middleware/choice_mapper_test.rb` → 0 failures
659
+
660
+ **Steps:**
661
+
662
+ - [ ] **Step 1: Write the failing test**
663
+
664
+ ```ruby
665
+ # test/unit/intercom/middleware/choice_mapper_test.rb — append inside the existing class
666
+ def test_duplicate_labels_both_resolve
667
+ mapper = build_mapper(choices: {"a" => "Savings", "b" => "Savings"})
668
+
669
+ assert_equal "a", resolve(mapper, "1")
670
+ assert_equal "b", resolve(mapper, "2")
671
+ assert_equal "b", resolve(mapper, "2. Savings")
672
+ end
673
+ ```
674
+
675
+ - [ ] **Step 2: Run to verify it fails**
676
+
677
+ Run: `bundle exec ruby -Itest test/unit/intercom/middleware/choice_mapper_test.rb`
678
+ Expected: FAIL on the `"2. Savings"` assertion
679
+
680
+ - [ ] **Step 3: Build the mapping from titles**
681
+
682
+ ```ruby
683
+ # lib/flow_chat/intercom/middleware/choice_mapper.rb — replace remember
684
+ FOLD = ->(string) { string.strip.downcase }
685
+
686
+ # Intercom always numbers, so the titles this builds are always
687
+ # distinct and every one of them is resolvable - which is what
688
+ # replaces the old `||=`, where a repeated label meant the second
689
+ # choice could not be picked by name at all.
690
+ def remember(choices)
691
+ numbered = {}
692
+ mapping = {}
693
+
694
+ FlowChat::ChoiceTitles.build(choices, UNCAPPED, fold: FOLD).each_with_index do |(key, _label, title, _truncated), index|
695
+ number = (index + 1).to_s
696
+ numbered[number] = title
697
+ mapping[number] = key
698
+ mapping[FOLD.call(title)] = key
699
+ end
700
+
701
+ @session.set(SESSION_KEY, mapping)
702
+ FlowChat.logger.debug { "Intercom::ChoiceMapper: Created mapping: #{mapping}" }
703
+ numbered
704
+ end
705
+ ```
706
+
707
+ Add `UNCAPPED = Float::INFINITY` beside `SESSION_KEY`.
708
+
709
+ - [ ] **Step 4: Run the tests**
710
+
711
+ Run: `bundle exec ruby -Itest test/unit/intercom/middleware/choice_mapper_test.rb`
712
+ Expected: PASS
713
+
714
+ - [ ] **Step 5: Commit**
715
+
716
+ ```bash
717
+ git add lib/flow_chat/intercom test/unit/intercom
718
+ git commit -m "fix(intercom): let a repeated label still be picked by name"
719
+ ```
720
+
721
+ ---
722
+
723
+ ### Task 7: Telegram resolves against a real map, sized in bytes
724
+
725
+ **Goal:** Telegram stops overflowing its 64-byte field on multibyte keys and stops making a choice unselectable when two keys share a prefix.
726
+
727
+ **Files:**
728
+ - Modify: `lib/flow_chat/telegram/middleware/choice_mapper.rb` (whole file)
729
+ - Modify: `lib/flow_chat/telegram/renderer.rb:75-80`
730
+ - Test: `test/unit/telegram/middleware/choice_mapper_test.rb`
731
+
732
+ **Acceptance Criteria:**
733
+ - [ ] `callback_data` never exceeds 64 bytes for any input, including emoji and CJK labels
734
+ - [ ] `callback_data` is always valid UTF-8
735
+ - [ ] Two labels sharing a 64-byte prefix are numbered, and both resolve
736
+ - [ ] The mapper rewrites `context.input` to the flow's key, rather than passing raw `callback_data` through
737
+ - [ ] A typed position still resolves
738
+
739
+ **Verify:** `bundle exec ruby -Itest test/unit/telegram/middleware/choice_mapper_test.rb` → 0 failures
740
+
741
+ **Steps:**
742
+
743
+ - [ ] **Step 1: Write the failing tests**
744
+
745
+ ```ruby
746
+ # test/unit/telegram/middleware/choice_mapper_test.rb — append inside the existing class
747
+ CALLBACK_DATA_LIMIT = 64
748
+
749
+ def test_callback_data_fits_the_byte_limit_for_multibyte_labels
750
+ mapper = build_mapper(choices: {"a" => "日本語のテキストです " * 5})
751
+
752
+ wire_values(mapper).each do |value|
753
+ assert_operator value.bytesize, :<=, CALLBACK_DATA_LIMIT
754
+ assert value.valid_encoding?
755
+ end
756
+ end
757
+
758
+ def test_labels_sharing_a_long_prefix_are_numbered_and_both_resolve
759
+ shared = "Transfer to the account ending in " * 3
760
+ mapper = build_mapper(choices: {"a" => "#{shared} one", "b" => "#{shared} two"})
761
+
762
+ values = wire_values(mapper)
763
+ assert_equal values.uniq.length, values.length, "callback_data must stay distinct after truncation"
764
+ assert_equal "a", resolve(mapper, values[0])
765
+ assert_equal "b", resolve(mapper, values[1])
766
+ end
767
+ ```
768
+
769
+ - [ ] **Step 2: Run to verify they fail**
770
+
771
+ Run: `bundle exec ruby -Itest test/unit/telegram/middleware/choice_mapper_test.rb`
772
+ Expected: FAIL — `callback_data` exceeds 64 bytes, and the two values are identical
773
+
774
+ - [ ] **Step 3: Give the mapper real maps**
775
+
776
+ ```ruby
777
+ # lib/flow_chat/telegram/middleware/choice_mapper.rb — replace the class body
778
+ # Telegram's callback_data is 1-64 *bytes*, not characters, and carries
779
+ # no character restrictions. Sizing it in characters let a multibyte
780
+ # label through to be rejected, and slicing the key at 64 characters
781
+ # let two long keys collapse onto one callback_data - which resolved to
782
+ # neither, so the choice could not be picked at all.
783
+ #
784
+ # Numbering is what keeps them apart, because a position prefix sits at
785
+ # the front and survives a cut from the right; a hash suffix would not.
786
+ class ChoiceMapper
787
+ SESSION_KEY = "telegram.choice_mapping"
788
+ POSITION_KEY = "telegram.position_mapping"
789
+ CALLBACK_DATA_LIMIT = 64
790
+
791
+ def initialize(app)
792
+ @app = app
793
+ end
794
+
795
+ def call(context)
796
+ @context = context
797
+ @session = context.session
798
+
799
+ resolve_input
800
+
801
+ type, prompt, choices, media = @app.call(context)
802
+
803
+ choices = remember(choices) if choices.present?
804
+
805
+ [type, prompt, choices, media]
806
+ end
807
+
808
+ private
809
+
810
+ def resolve_input
811
+ return if @context.input.blank?
812
+
813
+ input = @context.input.to_s
814
+ resolved = mapping[input] || positions[input]
815
+ return unless resolved
816
+
817
+ FlowChat.logger.info { "Telegram::ChoiceMapper: Resolving #{input} to #{resolved}" }
818
+ @context.input = resolved
819
+ end
820
+
821
+ def remember(choices)
822
+ wire_choices = {}
823
+ choice_mapping = {}
824
+
825
+ FlowChat::ChoiceTitles.build(choices, CALLBACK_DATA_LIMIT, measure: :bytes).each do |key, _label, title, _truncated|
826
+ wire_choices[title] = title
827
+ choice_mapping[title] = key
828
+ end
829
+
830
+ @session.set(SESSION_KEY, choice_mapping)
831
+ @session.set(POSITION_KEY, choices.keys.map.with_index(1) { |key, i| [i.to_s, key.to_s] }.to_h)
832
+
833
+ wire_choices
834
+ end
835
+
836
+ def mapping
837
+ @session.get(SESSION_KEY) || {}
838
+ end
839
+
840
+ def positions
841
+ @session.get(POSITION_KEY) || {}
842
+ end
843
+ end
844
+ ```
845
+
846
+ - [ ] **Step 4: Stop the renderer re-truncating by characters**
847
+
848
+ ```ruby
849
+ # lib/flow_chat/telegram/renderer.rb:75-80 — replace
850
+ buttons = choice_hash.map do |key, value|
851
+ {
852
+ text: FlowChat::TextTruncator.truncate(value.to_s, 64, measure: :bytes),
853
+ callback_data: key.to_s
854
+ }
855
+ end
856
+ ```
857
+
858
+ The mapper has already sized the key to 64 bytes, so `callback_data` needs no further cutting — cutting it again here is what reintroduces collisions.
859
+
860
+ - [ ] **Step 5: Run the tests**
861
+
862
+ Run: `bundle exec ruby -Itest test/unit/telegram/middleware/choice_mapper_test.rb`
863
+ Expected: PASS
864
+
865
+ - [ ] **Step 6: Commit**
866
+
867
+ ```bash
868
+ git add lib/flow_chat/telegram test/unit/telegram
869
+ git commit -m "fix(telegram): size callback data in bytes and keep it distinct"
870
+ ```
871
+
872
+ ---
873
+
874
+ ### Task 8: Pin USSD's exemption
875
+
876
+ **Goal:** Record why USSD is the one mapper that needs no fold, so a later change does not "unify" it into a bug.
877
+
878
+ **Files:**
879
+ - Modify: `lib/flow_chat/ussd/middleware/choice_mapper.rb:60-70` (comment only)
880
+ - Test: `test/unit/ussd/middleware/choice_mapper_test.rb`
881
+
882
+ **Acceptance Criteria:**
883
+ - [ ] A test proves duplicate labels still resolve distinctly on USSD
884
+ - [ ] The comment states the invariant: USSD resolves on position, which is injective by construction
885
+
886
+ **Verify:** `bundle exec ruby -Itest test/unit/ussd/middleware/choice_mapper_test.rb` → 0 failures
887
+
888
+ **Steps:**
889
+
890
+ - [ ] **Step 1: Write the test**
891
+
892
+ ```ruby
893
+ # test/unit/ussd/middleware/choice_mapper_test.rb — append inside the existing class
894
+ # USSD needs no fold and no aliasing: a numeric keypad only ever sends a
895
+ # position, and positions are unique by construction. That is the same
896
+ # invariant every other mapper now has to be given explicitly.
897
+ def test_duplicate_labels_still_resolve_distinctly
898
+ mapper = build_mapper(choices: {"a" => "Savings", "b" => "Savings"})
899
+
900
+ assert_equal "a", resolve(mapper, "1")
901
+ assert_equal "b", resolve(mapper, "2")
902
+ end
903
+ ```
904
+
905
+ - [ ] **Step 2: Run it**
906
+
907
+ Run: `bundle exec ruby -Itest test/unit/ussd/middleware/choice_mapper_test.rb`
908
+ Expected: PASS immediately — this pins existing behaviour rather than changing it
909
+
910
+ - [ ] **Step 3: Add the invariant comment**
911
+
912
+ ```ruby
913
+ # USSD is deliberately exempt from the fold every other mapper takes.
914
+ # A numeric keypad can only send a position, and positions are unique
915
+ # by construction, so the relation this resolves on is already
916
+ # injective - there is no equivalence under which two choices could
917
+ # collapse. Every other mapper has to be *given* that guarantee, by
918
+ # numbering a set whose titles collide under its own fold.
919
+ def create_numbered_mapping(choices)
920
+ ```
921
+
922
+ - [ ] **Step 4: Commit**
923
+
924
+ ```bash
925
+ git add lib/flow_chat/ussd test/unit/ussd
926
+ git commit -m "docs(ussd): record why position resolution needs no aliasing"
927
+ ```
928
+
929
+ ---
930
+
931
+ ### Task 9: Update the choice documentation
932
+
933
+ **Goal:** Bring the docs in line, including the two stale comments found during review.
934
+
935
+ **Files:**
936
+ - Modify: `docs/platforms/whatsapp.md`, `docs/platforms/telegram.md` (choice sections)
937
+ - Modify: `docs/gateway-development.md` (choice mapper contract)
938
+
939
+ **Acceptance Criteria:**
940
+ - [ ] No doc refers to generated ids, alias maps, or `IdGenerator`
941
+ - [ ] The fold/measure contract is documented for anyone writing a new gateway
942
+ - [ ] The USSD exemption is stated
943
+
944
+ **Verify:** `grep -rn "IdGenerator\|alias_mapping" docs/ --include=*.md | grep -v superpowers/` → no matches
945
+
946
+ **Steps:**
947
+
948
+ - [ ] **Step 1: Find every stale reference**
949
+
950
+ Run: `grep -rn "IdGenerator\|alias mapping\|alias_mapping\|generated id" docs/ --include=*.md | grep -v superpowers/`
951
+
952
+ - [ ] **Step 2: Rewrite the choice-mapper contract in `docs/gateway-development.md`**
953
+
954
+ State the invariant plainly: a gateway's choice mapper must pass `ChoiceTitles` the same fold its resolver matches on, and the same measure its platform sizes fields in. Give the four folds in use — identity (Meta), `strip.downcase` (HTTP, Intercom), byte-truncation (Telegram), position (USSD, exempt).
955
+
956
+ - [ ] **Step 3: Commit**
957
+
958
+ ```bash
959
+ git add docs/
960
+ git commit -m "docs(choices): describe the fold a mapper owes its resolver"
961
+ ```
962
+
963
+ ---
964
+
965
+ ## Out of scope
966
+
967
+ Two defects found in the same review are independent of this plan and are **not** addressed here. Track them separately:
968
+
969
+ - **`message.sent` fires on a failed delivery** — `report_delivery_failure` returns `nil`, but callers instrument `MESSAGE_SENT` regardless (`lib/flow_chat/instrumentation.rb:61`, `lib/flow_chat/meta/messaging_gateway.rb:373`, plus the WhatsApp, Intercom and Telegram equivalents).
970
+ - **Async double-publishes status/echo events** — the foreground pass publishes them before enqueueing, and the background job republishes them (`lib/flow_chat/meta/messaging_gateway.rb:220`; mirrored in `whatsapp/gateway/cloud_api.rb`).
971
+
972
+ Also unaddressed, and worth a line when someone next touches markdown rendering: `renderers/markdown_support.rb:80`'s non-greedy `<ul>` regex leaks raw tags on nested lists.