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.
- checksums.yaml +4 -4
- data/.cliff.toml +74 -0
- data/.github/workflows/ci.yml +2 -3
- data/.github/workflows/pages.yml +43 -0
- data/.github/workflows/release.yml +56 -0
- data/.standard.yml +4 -0
- data/CHANGELOG.md +48 -0
- data/CLAUDE.md +327 -0
- data/CONTRIBUTING.md +134 -0
- data/Gemfile +1 -0
- data/README.md +189 -133
- data/Rakefile +17 -2
- data/SECURITY.md +42 -349
- data/docs/architecture.md +83 -0
- data/docs/async-background-processing.md +64 -0
- data/docs/configuration.md +110 -287
- data/docs/factory-pattern.md +58 -0
- data/docs/gateway-context-variables.md +168 -0
- data/docs/gateway-development.md +159 -0
- data/docs/getting-started.md +90 -0
- data/docs/instrumentation.md +95 -175
- data/docs/platforms/instagram.md +278 -0
- data/docs/platforms/messenger.md +205 -0
- data/docs/platforms/telegram.md +109 -0
- data/docs/platforms/ussd.md +78 -0
- data/docs/platforms/whatsapp.md +147 -0
- data/docs/superpowers/plans/2026-07-09-inbound-media-support.md +732 -0
- data/docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json +58 -0
- data/docs/superpowers/plans/2026-08-10-messenger-instagram.md +4064 -0
- data/docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json +226 -0
- data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md +972 -0
- data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json +88 -0
- data/docs/superpowers/specs/2026-07-09-inbound-media-support-design.md +195 -0
- data/docs/superpowers/specs/2026-08-10-messenger-instagram-design.md +391 -0
- data/docs/testing.md +33 -426
- data/examples/custom_session_id_example.rb +119 -0
- data/examples/http_controller.rb +22 -20
- data/examples/intercom_configuration_example.rb +113 -0
- data/examples/intercom_controller.rb +182 -0
- data/examples/multi_tenant_whatsapp_controller.rb +63 -168
- data/examples/simulator_controller.rb +0 -1
- data/examples/ussd_controller.rb +88 -160
- data/examples/whatsapp_controller.rb +18 -17
- data/examples/whatsapp_media_examples.rb +27 -79
- data/flow_chat.gemspec +4 -0
- data/lib/flow_chat/app.rb +211 -0
- data/lib/flow_chat/async_job.rb +176 -0
- data/lib/flow_chat/choice_titles.rb +95 -0
- data/lib/flow_chat/config.rb +126 -23
- data/lib/flow_chat/delivery_error.rb +9 -0
- data/lib/flow_chat/{base_executor.rb → executor.rb} +6 -11
- data/lib/flow_chat/factory.rb +94 -0
- data/lib/flow_chat/gateway_async_support.rb +106 -0
- data/lib/flow_chat/generic_async_job.rb +30 -0
- data/lib/flow_chat/http/configuration_error.rb +9 -0
- data/lib/flow_chat/http/gateway/simple.rb +104 -36
- data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
- data/lib/flow_chat/http/renderer.rb +3 -3
- data/lib/flow_chat/input.rb +86 -0
- data/lib/flow_chat/instagram/client.rb +32 -0
- data/lib/flow_chat/instagram/configuration.rb +147 -0
- data/lib/flow_chat/instagram/configuration_error.rb +7 -0
- data/lib/flow_chat/instagram/gateway/send_api.rb +63 -0
- data/lib/flow_chat/instagram/middleware/choice_mapper.rb +22 -0
- data/lib/flow_chat/instagram/renderer.rb +23 -0
- data/lib/flow_chat/instrumentation/metrics_collector.rb +6 -1
- data/lib/flow_chat/instrumentation/setup.rb +1 -1
- data/lib/flow_chat/instrumentation.rb +182 -0
- data/lib/flow_chat/intercom/client.rb +161 -0
- data/lib/flow_chat/intercom/configuration.rb +102 -0
- data/lib/flow_chat/intercom/configuration_error.rb +9 -0
- data/lib/flow_chat/intercom/gateway/intercom_api.rb +420 -0
- data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
- data/lib/flow_chat/intercom/renderer.rb +123 -0
- data/lib/flow_chat/media.rb +121 -0
- data/lib/flow_chat/messenger/client.rb +264 -0
- data/lib/flow_chat/messenger/configuration.rb +103 -0
- data/lib/flow_chat/messenger/configuration_error.rb +9 -0
- data/lib/flow_chat/messenger/gateway/send_api.rb +42 -0
- data/lib/flow_chat/messenger/middleware/choice_mapper.rb +185 -0
- data/lib/flow_chat/messenger/renderer.rb +150 -0
- data/lib/flow_chat/meta/challenge.rb +24 -0
- data/lib/flow_chat/meta/choice_ladder.rb +37 -0
- data/lib/flow_chat/meta/configuration_error.rb +7 -0
- data/lib/flow_chat/meta/gateway_identity.rb +38 -0
- data/lib/flow_chat/meta/messaging_gateway.rb +468 -0
- data/lib/flow_chat/meta/signature.rb +30 -0
- data/lib/flow_chat/meta/signature_validation.rb +66 -0
- data/lib/flow_chat/meta/webhook_verification.rb +43 -0
- data/lib/flow_chat/named_configuration.rb +65 -0
- data/lib/flow_chat/phone_number_util.rb +37 -35
- data/lib/flow_chat/processor.rb +188 -0
- data/lib/flow_chat/prompt.rb +13 -16
- data/lib/flow_chat/renderers/markdown_support.rb +167 -0
- data/lib/flow_chat/security.rb +76 -0
- data/lib/flow_chat/session/middleware.rb +36 -11
- data/lib/flow_chat/simulator/controller.rb +31 -15
- data/lib/flow_chat/simulator/views/simulator.html.erb +184 -20
- data/lib/flow_chat/telegram/client.rb +283 -0
- data/lib/flow_chat/telegram/configuration.rb +78 -0
- data/lib/flow_chat/telegram/configuration_error.rb +9 -0
- data/lib/flow_chat/telegram/gateway/bot_api.rb +318 -0
- data/lib/flow_chat/telegram/middleware/choice_mapper.rb +96 -0
- data/lib/flow_chat/telegram/renderer.rb +133 -0
- data/lib/flow_chat/telegram.rb +7 -0
- data/lib/flow_chat/text_truncator.rb +75 -0
- data/lib/flow_chat/ussd/gateway/nalo.rb +24 -4
- data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
- data/lib/flow_chat/ussd/middleware/pagination.rb +9 -5
- data/lib/flow_chat/ussd/renderer.rb +1 -1
- data/lib/flow_chat/version.rb +1 -1
- data/lib/flow_chat/whatsapp/client.rb +158 -20
- data/lib/flow_chat/whatsapp/configuration.rb +13 -52
- data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
- data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +335 -248
- data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +234 -0
- data/lib/flow_chat/whatsapp/renderer.rb +259 -64
- data/lib/flow_chat.rb +1 -1
- data/lib/tasks/release.rake +165 -0
- data/site/.nojekyll +0 -0
- data/site/.og-card.html +89 -0
- data/site/favicon.svg +6 -0
- data/site/index.html +209 -0
- data/site/og.png +0 -0
- metadata +132 -25
- data/docs/flows.md +0 -320
- data/docs/http-gateway-protocol.md +0 -432
- data/docs/images/simulator.png +0 -0
- data/docs/media.md +0 -153
- data/docs/sessions.md +0 -433
- data/docs/ussd-setup.md +0 -322
- data/docs/whatsapp-setup.md +0 -162
- data/examples/whatsapp_message_job.rb +0 -113
- data/lib/flow_chat/base_app.rb +0 -86
- data/lib/flow_chat/base_processor.rb +0 -146
- data/lib/flow_chat/http/app.rb +0 -6
- data/lib/flow_chat/http/middleware/executor.rb +0 -24
- data/lib/flow_chat/http/processor.rb +0 -33
- data/lib/flow_chat/session/rails_session_store.rb +0 -68
- data/lib/flow_chat/ussd/app.rb +0 -6
- data/lib/flow_chat/ussd/gateway/nsano.rb +0 -96
- data/lib/flow_chat/ussd/middleware/executor.rb +0 -24
- data/lib/flow_chat/ussd/processor.rb +0 -39
- data/lib/flow_chat/whatsapp/app.rb +0 -29
- data/lib/flow_chat/whatsapp/middleware/executor.rb +0 -24
- data/lib/flow_chat/whatsapp/processor.rb +0 -32
- 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.
|