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,4064 @@
1
+ # Messenger and Instagram DM Support 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:** Add Facebook Messenger and Instagram DM gateways to FlowChat at parity with the WhatsApp gateway, on one shared Meta webhook implementation, and fix two WhatsApp bugs found while designing the choice ladder.
6
+
7
+ **Architecture:** `FlowChat::Meta::` holds what all three Meta platforms share: `X-Hub-Signature-256` validation, `hub.challenge` verification, and `MessagingGateway`, which implements the `entry[].messaging[]` envelope once. `Messenger::Gateway::SendApi` and `Instagram::Gateway::SendApi` each subclass `MessagingGateway` and override four hooks (platform symbol, configuration class, renderer class, account-id check). Choices render down a ladder (quick replies, carousel, numbered text) whose rung is decided by one shared helper so the renderer and the choice mapper cannot disagree.
8
+
9
+ **Tech Stack:** Ruby, Zeitwerk autoloading, Minitest, WebMock, Kramdown, ActiveSupport. Meta Graph API v23.0.
10
+
11
+ **User Verification:** YES. Two facts cannot be settled from this machine and need the user: (1) whether Instagram-via-Facebook-Login delivers webhooks under `object: "page"` or `object: "instagram"`, readable only from their Meta app dashboard, and (2) whether the Instagram carousel is legible for plain option menus on a real device. Task 20 is a dedicated verification checkpoint for both.
12
+
13
+ **Spec:** `docs/superpowers/specs/2026-08-10-messenger-instagram-design.md`
14
+
15
+ ---
16
+
17
+ ## Conventions for every task
18
+
19
+ - Run one test file with `bundle exec ruby -Itest test/unit/path_test.rb`, one test with `bundle exec ruby -Itest test/unit/path_test.rb -n test_name`, everything with `bundle exec rake test`. The `bundle exec` prefix is required: plain `ruby -Itest` bypasses Bundler and dies on `cannot load such file -- minitest/mock`. Later tasks in this plan write the bare form in places; prefix it anyway.
20
+ - Never stage or commit anything beyond the files a task names.
21
+ - Logging uses block syntax: `FlowChat.logger.debug { "..." }`.
22
+ - No `respond_to?` guards. If a collaborator is required, call it.
23
+ - **Inside a standalone `module`, always fully qualify `FlowChat::Instrumentation::Events::X`.** Bare `Events::X` raises `NameError` there: Ruby resolves an unqualified constant through `Module.nesting` and then the ancestors of the cref, not the ancestors of whichever class later includes the module. The bare form works only in a class body that itself includes `FlowChat::Instrumentation`, which is why the old `CloudApi` code got away with it. Task 2 hit this. Code blocks later in this plan sometimes show the bare form inside modules; qualify it anyway. Classes that `include FlowChat::Instrumentation` directly (such as `Meta::MessagingGateway`) are fine either way.
24
+ - Docs prose: dense and plain, no marketing adjectives, **no em-dashes**.
25
+ - **The suite's flakiness was real and is now fixed. Do not repeat the claim that it is inherently flaky.** Two causes, both since resolved: `metrics_collector_test.rb` slept for the durations it asserted (now stated via a constructed `Event`), and `security_test.rb` could leave `ActiveSupport::SecurityUtils` set to `nil` for the rest of the process, which broke every later `secure_compare` and produced cascades of thirty or more unrelated failures. Benchmarks also moved out of `rake test` into `rake benchmark`.
26
+ - **The suite is order-dependent by nature, so check a failure against a seed before blaming your change.** Minitest randomizes the order. Seeds 1 to 140 pass as of `bd42d12`. If you see a failure, re-run with `bundle exec rake test TESTOPTS="--seed=N"` for the seed printed in the output: a failure that reproduces on a fixed seed is real, and one that moves around is order pollution worth reporting rather than working around. Do not describe the suite as "deterministic" without saying which seeds you actually ran.
27
+
28
+ ## File Structure
29
+
30
+ ### Created
31
+
32
+ | File | Responsibility |
33
+ |---|---|
34
+ | `lib/flow_chat/meta/signature_validation.rb` | `X-Hub-Signature-256` HMAC check, shared by 3 platforms |
35
+ | `lib/flow_chat/meta/webhook_verification.rb` | `hub.mode`/`hub.verify_token`/`hub.challenge` exchange |
36
+ | `lib/flow_chat/meta/choice_ladder.rb` | Decides which rung renders a given choice count |
37
+ | `lib/flow_chat/meta/messaging_gateway.rb` | The `entry[].messaging[]` envelope, dispatch, echoes, statuses, context |
38
+ | `lib/flow_chat/named_configuration.rb` | Named-configuration registry with per-class storage |
39
+ | `lib/flow_chat/id_generator.rb` | Moved from `whatsapp/id_generator.rb`, max length now configurable |
40
+ | `lib/flow_chat/messenger/configuration.rb` | Messenger credentials |
41
+ | `lib/flow_chat/messenger/client.rb` | Send API calls, text splitting, attachment upload |
42
+ | `lib/flow_chat/messenger/renderer.rb` | Messenger choice ladder and plain-text conversion |
43
+ | `lib/flow_chat/messenger/gateway/send_api.rb` | Messenger subclass of `MessagingGateway` |
44
+ | `lib/flow_chat/messenger/middleware/choice_mapper.rb` | Payload and position mapping back to choice keys |
45
+ | `lib/flow_chat/instagram/configuration.rb` | Instagram credentials |
46
+ | `lib/flow_chat/instagram/client.rb` | Instagram Send API calls, byte-based splitting |
47
+ | `lib/flow_chat/instagram/renderer.rb` | Instagram ladder, always numbers the body |
48
+ | `lib/flow_chat/instagram/gateway/send_api.rb` | Instagram subclass of `MessagingGateway` |
49
+ | `lib/flow_chat/instagram/middleware/choice_mapper.rb` | Instagram choice mapping |
50
+ | `docs/platforms/messenger.md`, `docs/platforms/instagram.md` | Platform guides |
51
+
52
+ ### Modified
53
+
54
+ | File | Change |
55
+ |---|---|
56
+ | `lib/flow_chat/whatsapp/gateway/cloud_api.rb` | Uses shared `Meta::` modules; derives `echo_origin` |
57
+ | `lib/flow_chat/whatsapp/configuration.rb` | Uses `NamedConfiguration` |
58
+ | `lib/flow_chat/telegram/configuration.rb` | Uses `NamedConfiguration` |
59
+ | `lib/flow_chat/intercom/configuration.rb` | Uses `NamedConfiguration` |
60
+ | `lib/flow_chat/whatsapp/renderer.rb` | List capped at 10 rows; numbered fallback above |
61
+ | `lib/flow_chat/whatsapp/middleware/choice_mapper.rb` | Uses `FlowChat::IdGenerator`; stores position map |
62
+ | `lib/flow_chat/renderers/markdown_support.rb` | Adds `to_plain_text` |
63
+ | `lib/flow_chat/session/middleware.rb` | `:messenger` and `:instagram` default to `:user_id` |
64
+ | `lib/flow_chat/config.rb` | Adds `Config.messenger` and `Config.instagram` |
65
+ | `README.md`, `docs/gateway-context-variables.md` | Both platforms documented |
66
+
67
+ ### Deleted
68
+
69
+ | File | Reason |
70
+ |---|---|
71
+ | `lib/flow_chat/whatsapp/id_generator.rb` | Moved to `lib/flow_chat/id_generator.rb` |
72
+
73
+ ---
74
+
75
+ # Phase 1: Shared extractions and WhatsApp fixes
76
+
77
+ Phase 1 touches only existing code and is covered by existing suites. It lands before either new platform so that phases 2 and 3 build on proven shared code.
78
+
79
+ ## Task 1: Extract Meta signature validation
80
+
81
+ **Goal:** One implementation of `X-Hub-Signature-256` validation, used by the WhatsApp gateway, with each platform keeping its own error class.
82
+
83
+ **Files:**
84
+ - Create: `lib/flow_chat/meta/signature_validation.rb`
85
+ - Modify: `lib/flow_chat/whatsapp/gateway/cloud_api.rb` (remove `valid_webhook_signature?`, lines 325-377)
86
+ - Test: `test/unit/whatsapp/gateway/cloud_api_test.rb` (existing, must stay green)
87
+
88
+ **Acceptance Criteria:**
89
+ - [ ] `FlowChat::Meta::SignatureValidation` provides `valid_webhook_signature?(request)`
90
+ - [ ] `skip_signature_validation` short-circuits to `true`
91
+ - [ ] A blank `app_secret` raises the *including gateway's* error class, not a shared one
92
+ - [ ] A missing `X-Hub-Signature-256` header returns `false`
93
+ - [ ] WhatsApp's three `assert_raises(FlowChat::Whatsapp::ConfigurationError)` tests still pass
94
+
95
+ **Verify:** `ruby -Itest test/unit/whatsapp/gateway/cloud_api_test.rb` → all pass, 0 failures
96
+
97
+ **Steps:**
98
+
99
+ - [ ] **Step 1: Confirm the existing tests pass before touching anything**
100
+
101
+ Run: `ruby -Itest test/unit/whatsapp/gateway/cloud_api_test.rb`
102
+ Expected: PASS. Record the assertion count so you can compare after.
103
+
104
+ - [ ] **Step 2: Create the shared module**
105
+
106
+ Create `lib/flow_chat/meta/signature_validation.rb`:
107
+
108
+ ```ruby
109
+ require "openssl"
110
+
111
+ module FlowChat
112
+ module Meta
113
+ # Raised when a Meta gateway cannot validate a signature because it was not
114
+ # configured to. Platforms override configuration_error_class to raise their own.
115
+ class ConfigurationError < StandardError; end
116
+
117
+ # X-Hub-Signature-256 validation, shared by every Meta webhook gateway.
118
+ #
119
+ # The including gateway must have @config responding to #app_secret and
120
+ # #skip_signature_validation. It may override platform_label, log_tag and
121
+ # configuration_error_class.
122
+ module SignatureValidation
123
+ def valid_webhook_signature?(request)
124
+ if @config.skip_signature_validation
125
+ FlowChat.logger.debug { "#{log_tag}: Webhook signature validation is disabled" }
126
+ return true
127
+ end
128
+
129
+ if @config.app_secret.blank?
130
+ error_msg = "#{platform_label} app_secret is required for webhook signature validation. " \
131
+ "Either configure app_secret or set skip_signature_validation=true to explicitly disable validation."
132
+ FlowChat.logger.error { "#{log_tag}: #{error_msg}" }
133
+ raise configuration_error_class, error_msg
134
+ end
135
+
136
+ signature_header = request.headers["X-Hub-Signature-256"]
137
+ unless signature_header
138
+ FlowChat.logger.warn { "#{log_tag}: No X-Hub-Signature-256 header found in request" }
139
+ return false
140
+ end
141
+
142
+ expected_signature = signature_header.sub("sha256=", "")
143
+
144
+ request.body.rewind
145
+ body = request.body.read
146
+ request.body.rewind
147
+
148
+ calculated_signature = OpenSSL::HMAC.hexdigest(
149
+ OpenSSL::Digest.new("sha256"),
150
+ @config.app_secret,
151
+ body
152
+ )
153
+
154
+ signature_valid = FlowChat::Security.secure_compare(expected_signature, calculated_signature)
155
+
156
+ if signature_valid
157
+ FlowChat.logger.debug { "#{log_tag}: Webhook signature validation successful" }
158
+ else
159
+ FlowChat.logger.warn { "#{log_tag}: Webhook signature validation failed - signatures do not match" }
160
+ end
161
+
162
+ signature_valid
163
+ rescue => e
164
+ # A misconfiguration is the developer's problem and must not be swallowed
165
+ # into a plain "invalid signature".
166
+ raise if e.is_a?(configuration_error_class)
167
+
168
+ FlowChat.logger.error { "#{log_tag}: Error validating webhook signature: #{e.class.name}: #{e.message}" }
169
+ false
170
+ end
171
+
172
+ private
173
+
174
+ def configuration_error_class
175
+ FlowChat::Meta::ConfigurationError
176
+ end
177
+
178
+ def platform_label
179
+ "Meta"
180
+ end
181
+
182
+ def log_tag
183
+ self.class.name.split("::").last
184
+ end
185
+ end
186
+ end
187
+ end
188
+ ```
189
+
190
+ - [ ] **Step 3: Point the WhatsApp gateway at it**
191
+
192
+ In `lib/flow_chat/whatsapp/gateway/cloud_api.rb`, add the include next to the existing ones:
193
+
194
+ ```ruby
195
+ include FlowChat::Instrumentation
196
+ include FlowChat::GatewayAsyncSupport
197
+ include FlowChat::Meta::SignatureValidation
198
+ ```
199
+
200
+ Delete the entire private `valid_webhook_signature?` method (lines 325-377) and add the three hook overrides in the private section:
201
+
202
+ ```ruby
203
+ def configuration_error_class
204
+ FlowChat::Whatsapp::ConfigurationError
205
+ end
206
+
207
+ def platform_label
208
+ "WhatsApp"
209
+ end
210
+
211
+ def log_tag
212
+ "CloudApi"
213
+ end
214
+ ```
215
+
216
+ - [ ] **Step 4: Run the WhatsApp gateway tests**
217
+
218
+ Run: `ruby -Itest test/unit/whatsapp/gateway/cloud_api_test.rb`
219
+ Expected: PASS, same assertion count as Step 1. The three `ConfigurationError` tests confirm the hook works.
220
+
221
+ - [ ] **Step 5: Run the full suite**
222
+
223
+ Run: `bundle exec rake test`
224
+ Expected: PASS, 0 failures, 0 errors.
225
+
226
+ - [ ] **Step 6: Commit**
227
+
228
+ ```bash
229
+ git add lib/flow_chat/meta/signature_validation.rb lib/flow_chat/whatsapp/gateway/cloud_api.rb
230
+ git commit -m "refactor(meta): share the webhook signature check between platforms"
231
+ ```
232
+
233
+ ---
234
+
235
+ ## Task 2: Extract Meta webhook verification
236
+
237
+ **Goal:** One implementation of the `hub.challenge` exchange, including the guard that a gateway with no verify token verifies nothing. Plus the identity seam both `Meta::` behavior modules depend on.
238
+
239
+ **Why the identity module:** the obvious version of `WebhookVerification` calls `log_tag` and `platform` but defines neither, so it would only work because `SignatureValidation` happens to be included in the same class. That is a hidden coupling between sibling modules that breaks the moment a gateway includes one without the other. After Task 1, `platform_label` and `configuration_error_class` already raise `NotImplementedError` when a gateway forgets them. Collect all four identity hooks in one module that both behavior modules name as a prerequisite, so the contract is stated once for the three platforms about to arrive.
240
+
241
+ **Files:**
242
+ - Create: `lib/flow_chat/meta/gateway_identity.rb`
243
+ - Create: `lib/flow_chat/meta/webhook_verification.rb`
244
+ - Create: `test/unit/meta/webhook_verification_test.rb`
245
+ - Modify: `lib/flow_chat/meta/signature_validation.rb` (take its identity hooks from the new module)
246
+ - Modify: `lib/flow_chat/whatsapp/gateway/cloud_api.rb` (remove `handle_verification`, lines 79-110)
247
+ - Test: `test/unit/whatsapp/gateway/cloud_api_test.rb` (existing, must stay green)
248
+
249
+ **Acceptance Criteria:**
250
+ - [ ] `FlowChat::Meta::GatewayIdentity` declares `platform`, `platform_label`, `configuration_error_class` (all `NotImplementedError` by default) and `log_tag` (derived from the class name)
251
+ - [ ] Both `SignatureValidation` and `WebhookVerification` work when included alone, with `GatewayIdentity` present, and neither depends on the other
252
+ - [ ] `FlowChat::Meta::WebhookVerification#handle_verification` renders the challenge on a token match
253
+ - [ ] A blank configured verify token returns `:forbidden` even when the request also sends a blank token
254
+ - [ ] `WEBHOOK_VERIFIED` and `WEBHOOK_FAILED` carry the including gateway's `platform`
255
+ - [ ] `handle_verification` is private, matching the visibility fix applied to `valid_webhook_signature?` in Task 1
256
+
257
+ **Verify:** `ruby -Itest test/unit/whatsapp/gateway/cloud_api_test.rb -n test_get_request_webhook_verification` → PASS
258
+
259
+ **Steps:**
260
+
261
+ - [ ] **Step 1: Create the identity module**
262
+
263
+ Create `lib/flow_chat/meta/gateway_identity.rb`:
264
+
265
+ ```ruby
266
+ module FlowChat
267
+ module Meta
268
+ # What a Meta gateway must say about itself.
269
+ #
270
+ # The behavior modules in this namespace need a handful of the same values:
271
+ # which platform this is, how to name it to a developer, which error class to
272
+ # raise, what to tag logs with. Declaring them here rather than in whichever
273
+ # behavior module happens to be included first means a gateway can include
274
+ # one behavior without the other, and a gateway that forgets a value fails
275
+ # loudly rather than borrowing another platform's.
276
+ #
277
+ # NotImplementedError rather than a default: it descends from ScriptError,
278
+ # not StandardError, so it travels through the bare rescue in
279
+ # SignatureValidation instead of being swallowed into a false return that
280
+ # would read as "invalid signature" and drop every webhook.
281
+ module GatewayIdentity
282
+ def platform
283
+ raise NotImplementedError, "#{self.class.name} must define #platform"
284
+ end
285
+
286
+ # The product's name as a developer reading an error message expects it,
287
+ # which is not always the constant: Whatsapp the module, WhatsApp the product.
288
+ def platform_label
289
+ raise NotImplementedError, "#{self.class.name} must define #platform_label"
290
+ end
291
+
292
+ def configuration_error_class
293
+ raise NotImplementedError, "#{self.class.name} must define #configuration_error_class"
294
+ end
295
+
296
+ # Derived, because every gateway's class name already ends in the tag its
297
+ # logs use. Override only to pin the tag against a class rename.
298
+ def log_tag
299
+ self.class.name.split("::").last
300
+ end
301
+ end
302
+ end
303
+ end
304
+ ```
305
+
306
+ Then in `lib/flow_chat/meta/signature_validation.rb`, delete the `platform_label`, `configuration_error_class` and `log_tag` hooks and include the identity module instead:
307
+
308
+ ```ruby
309
+ module SignatureValidation
310
+ include FlowChat::Meta::GatewayIdentity
311
+ ```
312
+
313
+ Confirm `test/unit/meta/signature_validation_test.rb` still passes: its fake defines the required hooks, so it should be unaffected.
314
+
315
+ - [ ] **Step 2: Create the verification module**
316
+
317
+ Create `lib/flow_chat/meta/webhook_verification.rb`, including the identity module so it does not depend on `SignatureValidation` being present:
318
+
319
+ ```ruby
320
+ module FlowChat
321
+ module Meta
322
+ # The GET handshake Meta performs when a webhook URL is registered.
323
+ #
324
+ # The including gateway must have @config responding to #verify_token,
325
+ # @controller, and must define #platform.
326
+ module WebhookVerification
327
+ def handle_verification(context)
328
+ params = @controller.request.params
329
+
330
+ verify_token = @config.verify_token
331
+ provided_token = params["hub.verify_token"]
332
+ challenge = params["hub.challenge"]
333
+
334
+ # A configuration with no verify token must not verify anything. Without
335
+ # the presence check a missing token on both sides compares equal, and
336
+ # anyone could claim the endpoint by asking for the challenge.
337
+ verified = verify_token.present? && FlowChat::Security.secure_compare(provided_token.to_s, verify_token)
338
+
339
+ FlowChat.logger.debug { "#{log_tag}: Webhook verification - provided token matches: #{verified}" }
340
+
341
+ if verified
342
+ instrument(Events::WEBHOOK_VERIFIED, {
343
+ challenge: challenge,
344
+ platform: platform
345
+ })
346
+
347
+ @controller.render plain: challenge
348
+ else
349
+ instrument(Events::WEBHOOK_FAILED, {
350
+ reason: "Invalid verify token",
351
+ platform: platform
352
+ })
353
+
354
+ @controller.head :forbidden
355
+ end
356
+ end
357
+ end
358
+ end
359
+ end
360
+ ```
361
+
362
+ - [ ] **Step 3: Test the module directly**
363
+
364
+ Create `test/unit/meta/webhook_verification_test.rb`, reusing the shared fake gateway that Task 1 moved into `test/support/`. Cover, at minimum:
365
+
366
+ ```ruby
367
+ def test_matching_token_renders_the_challenge
368
+ def test_wrong_token_is_forbidden
369
+ def test_blank_configured_token_is_forbidden_even_when_the_request_token_is_blank
370
+ def test_verified_event_carries_the_platform
371
+ ```
372
+
373
+ The blank-token case is the security-relevant one: without the `verify_token.present?` guard, a missing token on both sides compares equal and anyone can claim the endpoint by asking for the challenge.
374
+
375
+ Also prove the modules are independent, which is the whole point of `GatewayIdentity`:
376
+
377
+ ```ruby
378
+ # Each behavior module must stand alone. Before GatewayIdentity existed,
379
+ # WebhookVerification only worked because SignatureValidation happened to be
380
+ # included alongside it and supplied log_tag.
381
+ def test_verification_works_without_signature_validation
382
+ gateway_class = Class.new do
383
+ include FlowChat::Instrumentation
384
+ include FlowChat::Meta::WebhookVerification
385
+
386
+ def platform = :test_platform
387
+ def platform_label = "Test"
388
+ def configuration_error_class = FlowChat::Meta::ConfigurationError
389
+ def log_tag = "TestGateway"
390
+ end
391
+
392
+ refute gateway_class.include?(FlowChat::Meta::SignatureValidation)
393
+ # then drive handle_verification and assert the challenge renders
394
+ end
395
+ ```
396
+
397
+ Note the explicit `log_tag` in that anonymous class: the derived default calls `self.class.name.split("::")`, and an anonymous class has a nil name, so it would raise. Named gateways are unaffected.
398
+
399
+ - [ ] **Step 4: Point the WhatsApp gateway at it**
400
+
401
+ Add the include:
402
+
403
+ ```ruby
404
+ include FlowChat::Meta::WebhookVerification
405
+ ```
406
+
407
+ Delete the private `handle_verification` method (lines 79-110) and add `platform` to the private section:
408
+
409
+ ```ruby
410
+ def platform
411
+ :whatsapp
412
+ end
413
+ ```
414
+
415
+ `platform_label` and `configuration_error_class` are already there from Task 1. `log_tag` was deleted in Task 1 as a proven no-op, so do not add it back.
416
+
417
+ - [ ] **Step 5: Run the verification tests**
418
+
419
+ Run: `bundle exec ruby -Itest test/unit/meta/webhook_verification_test.rb`
420
+ Expected: PASS.
421
+
422
+ Run: `bundle exec ruby -Itest test/unit/whatsapp/gateway/cloud_api_test.rb -n /verification|verify_token/`
423
+ Expected: PASS.
424
+
425
+ - [ ] **Step 6: Run the full suite**
426
+
427
+ Run: `bundle exec rake test`
428
+ Expected: PASS, 0 failures.
429
+
430
+ - [ ] **Step 7: Commit**
431
+
432
+ ```bash
433
+ git add lib/flow_chat/meta/gateway_identity.rb lib/flow_chat/meta/webhook_verification.rb \
434
+ lib/flow_chat/meta/signature_validation.rb lib/flow_chat/whatsapp/gateway/cloud_api.rb \
435
+ test/unit/meta/webhook_verification_test.rb
436
+ git commit -m "refactor(meta): share the verification handshake and name the identity seam
437
+
438
+ The verification module needs the same few facts about a gateway that the
439
+ signature module does. Taking them from whichever module happened to be
440
+ included first was a coupling waiting to break, so both now include one
441
+ identity module that states the contract."
442
+ ```
443
+
444
+ ---
445
+
446
+ ## Task 3: Extract the named-configuration registry
447
+
448
+ **Goal:** One registry implementation with per-class storage, replacing three verbatim copies.
449
+
450
+ **Files:**
451
+ - Create: `lib/flow_chat/named_configuration.rb`
452
+ - Create: `test/unit/named_configuration_test.rb`
453
+ - Modify: `lib/flow_chat/whatsapp/configuration.rb:46-105`, `lib/flow_chat/telegram/configuration.rb:46-85`, `lib/flow_chat/intercom/configuration.rb:53-95`
454
+
455
+ **Acceptance Criteria:**
456
+ - [ ] `register`, `get`, `exists?`, `configuration_names`, `clear_all!`, `register_as` all provided by the module
457
+ - [ ] Storage is **per including class**: registering `:default` on Messenger does not make `Whatsapp::Configuration.exists?(:default)` true
458
+ - [ ] `get` on a missing name raises `ArgumentError` with each platform's existing message text preserved: `"WhatsApp configuration 'x' not found"`, `"Telegram configuration 'x' not found"`, `"Intercom configuration 'x' not found"`
459
+
460
+ **Verify:** `ruby -Itest test/unit/named_configuration_test.rb && ruby -Itest test/unit/telegram/configuration_test.rb && ruby -Itest test/unit/intercom/configuration_test.rb` → all pass
461
+
462
+ **Steps:**
463
+
464
+ - [ ] **Step 1: Write the failing test, isolation first**
465
+
466
+ Create `test/unit/named_configuration_test.rb`:
467
+
468
+ ```ruby
469
+ require "test_helper"
470
+
471
+ class NamedConfigurationTest < Minitest::Test
472
+ def setup
473
+ FlowChat::Whatsapp::Configuration.clear_all!
474
+ FlowChat::Telegram::Configuration.clear_all!
475
+ end
476
+
477
+ def teardown
478
+ FlowChat::Whatsapp::Configuration.clear_all!
479
+ FlowChat::Telegram::Configuration.clear_all!
480
+ end
481
+
482
+ # The bug a shared @@configurations would have introduced: one merged registry
483
+ # across every platform.
484
+ def test_registries_are_per_class
485
+ FlowChat::Whatsapp::Configuration.new(:shared_name)
486
+
487
+ assert FlowChat::Whatsapp::Configuration.exists?(:shared_name)
488
+ refute FlowChat::Telegram::Configuration.exists?(:shared_name)
489
+ end
490
+
491
+ def test_get_returns_the_registered_configuration
492
+ config = FlowChat::Whatsapp::Configuration.new(:acme)
493
+
494
+ assert_same config, FlowChat::Whatsapp::Configuration.get(:acme)
495
+ end
496
+
497
+ def test_get_raises_with_the_platform_label
498
+ error = assert_raises(ArgumentError) { FlowChat::Whatsapp::Configuration.get(:missing) }
499
+ assert_equal "WhatsApp configuration 'missing' not found", error.message
500
+
501
+ error = assert_raises(ArgumentError) { FlowChat::Telegram::Configuration.get(:missing) }
502
+ assert_equal "Telegram configuration 'missing' not found", error.message
503
+ end
504
+
505
+ def test_configuration_names_lists_only_that_platform
506
+ FlowChat::Whatsapp::Configuration.new(:wa_one)
507
+ FlowChat::Telegram::Configuration.new(:tg_one)
508
+
509
+ assert_equal [:wa_one], FlowChat::Whatsapp::Configuration.configuration_names
510
+ assert_equal [:tg_one], FlowChat::Telegram::Configuration.configuration_names
511
+ end
512
+ end
513
+ ```
514
+
515
+ - [ ] **Step 2: Run it and watch it fail**
516
+
517
+ Run: `ruby -Itest test/unit/named_configuration_test.rb`
518
+ Expected: FAIL. The per-class test may pass by accident today (each class has its own `@@configurations`), but `test_get_raises_with_the_platform_label` and the others should run green only once the module exists and is wired. Confirm the file at least loads.
519
+
520
+ - [ ] **Step 3: Write the module**
521
+
522
+ Create `lib/flow_chat/named_configuration.rb`:
523
+
524
+ ```ruby
525
+ module FlowChat
526
+ # The named-configuration registry every platform's Configuration shares.
527
+ #
528
+ # Storage is a class-level ivar on the including class, not a class variable.
529
+ # A @@configurations in a shared module would give every platform one merged
530
+ # registry, so a name registered for Messenger would resolve for WhatsApp.
531
+ module NamedConfiguration
532
+ def self.included(base)
533
+ base.extend(ClassMethods)
534
+ end
535
+
536
+ module ClassMethods
537
+ def configurations
538
+ @configurations ||= {}
539
+ end
540
+
541
+ def register(name, config)
542
+ FlowChat.logger.debug { "#{self.name}: Registering configuration '#{name}'" }
543
+ configurations[name.to_sym] = config
544
+ end
545
+
546
+ def get(name)
547
+ config = configurations[name.to_sym]
548
+ unless config
549
+ FlowChat.logger.error { "#{self.name}: Configuration '#{name}' not found" }
550
+ raise ArgumentError, "#{configuration_label} configuration '#{name}' not found"
551
+ end
552
+
553
+ FlowChat.logger.debug { "#{self.name}: Retrieved configuration '#{name}'" }
554
+ config
555
+ end
556
+
557
+ def exists?(name)
558
+ configurations.key?(name.to_sym)
559
+ end
560
+
561
+ def configuration_names
562
+ configurations.keys
563
+ end
564
+
565
+ def clear_all!
566
+ FlowChat.logger.debug { "#{self.name}: Clearing all registered configurations" }
567
+ configurations.clear
568
+ end
569
+
570
+ # The platform's name as it appears in the not-found message. Overridden
571
+ # where the constant name and the product name differ, as with WhatsApp.
572
+ def configuration_label
573
+ name.split("::")[-2]
574
+ end
575
+ end
576
+
577
+ def register_as(name)
578
+ FlowChat.logger.debug { "#{self.class.name}: Registering configuration as '#{name}'" }
579
+ @name = name.to_sym
580
+ self.class.register(@name, self)
581
+ self
582
+ end
583
+ end
584
+ end
585
+ ```
586
+
587
+ - [ ] **Step 4: Wire the three existing classes**
588
+
589
+ In `lib/flow_chat/whatsapp/configuration.rb`, delete `@@configurations = {}` and the methods `self.register`, `self.get`, `self.exists?`, `self.configuration_names`, `self.clear_all!` and `register_as` (lines 46-105 region). Add near the top of the class body:
590
+
591
+ ```ruby
592
+ include FlowChat::NamedConfiguration
593
+
594
+ # "Whatsapp" is the constant, "WhatsApp" is the product.
595
+ def self.configuration_label
596
+ "WhatsApp"
597
+ end
598
+ ```
599
+
600
+ Do the same in `lib/flow_chat/telegram/configuration.rb` and `lib/flow_chat/intercom/configuration.rb`, but without the `configuration_label` override: `name.split("::")[-2]` already yields `"Telegram"` and `"Intercom"`.
601
+
602
+ - [ ] **Step 5: Run the tests**
603
+
604
+ Run: `ruby -Itest test/unit/named_configuration_test.rb`
605
+ Expected: PASS, 4 tests.
606
+
607
+ Run: `ruby -Itest test/unit/telegram/configuration_test.rb && ruby -Itest test/unit/intercom/configuration_test.rb`
608
+ Expected: PASS. These are the regression net for the two migrations that have direct coverage.
609
+
610
+ - [ ] **Step 6: Run the full suite**
611
+
612
+ Run: `bundle exec rake test`
613
+ Expected: PASS, 0 failures. WhatsApp's configuration has no direct test, so its migration rides on the gateway and client suites.
614
+
615
+ - [ ] **Step 7: Commit**
616
+
617
+ ```bash
618
+ git add lib/flow_chat/named_configuration.rb test/unit/named_configuration_test.rb \
619
+ lib/flow_chat/whatsapp/configuration.rb lib/flow_chat/telegram/configuration.rb \
620
+ lib/flow_chat/intercom/configuration.rb
621
+ git commit -m "refactor(config): keep the named configuration registry in one place"
622
+ ```
623
+
624
+ ---
625
+
626
+ ## Task 4: Move IdGenerator up and make its cap configurable
627
+
628
+ **Goal:** `FlowChat::IdGenerator` usable by any platform, with the maximum length passed in rather than fixed at WhatsApp's 256.
629
+
630
+ **Files:**
631
+ - Create: `lib/flow_chat/id_generator.rb`
632
+ - Delete: `lib/flow_chat/whatsapp/id_generator.rb`
633
+ - Modify: `lib/flow_chat/whatsapp/middleware/choice_mapper.rb:1` (the `require_relative`) and its `IdGenerator.new` call
634
+ - Move: `test/unit/whatsapp/id_generator_test.rb` to `test/unit/id_generator_test.rb`
635
+
636
+ **Acceptance Criteria:**
637
+ - [ ] `FlowChat::IdGenerator.new(max_length: 1000)` truncates at 1000
638
+ - [ ] `FlowChat::IdGenerator.new` still defaults to 256, so WhatsApp behavior is unchanged
639
+ - [ ] Duplicate labels still get a hash suffix, and the suffix still fits inside the cap
640
+ - [ ] `FlowChat::Whatsapp::IdGenerator` no longer exists
641
+
642
+ **Verify:** `ruby -Itest test/unit/id_generator_test.rb` → PASS
643
+
644
+ **Steps:**
645
+
646
+ - [ ] **Step 1: Move the file and the test**
647
+
648
+ ```bash
649
+ git mv lib/flow_chat/whatsapp/id_generator.rb lib/flow_chat/id_generator.rb
650
+ git mv test/unit/whatsapp/id_generator_test.rb test/unit/id_generator_test.rb
651
+ ```
652
+
653
+ - [ ] **Step 2: Reopen the class one level up and parameterize the cap**
654
+
655
+ In `lib/flow_chat/id_generator.rb`, change the module nesting from `FlowChat::Whatsapp::IdGenerator` to `FlowChat::IdGenerator`, replace the `MAX_ID_LENGTH` constant usage with an instance attribute, and keep `HASH_SUFFIX_LENGTH`:
656
+
657
+ ```ruby
658
+ require "digest"
659
+
660
+ module FlowChat
661
+ # Generates platform-safe ids from choice labels.
662
+ #
663
+ # Interactive replies are identified by an id rather than by the label the user
664
+ # saw, and every platform caps that id: WhatsApp list rows at 256 characters,
665
+ # Messenger quick-reply payloads at 1000. The cap is a constructor argument so
666
+ # one generator serves all of them.
667
+ class IdGenerator
668
+ DEFAULT_MAX_ID_LENGTH = 256
669
+ HASH_SUFFIX_LENGTH = 3
670
+
671
+ attr_reader :max_length
672
+
673
+ def initialize(max_length: DEFAULT_MAX_ID_LENGTH)
674
+ @max_length = max_length
675
+ @generated_ids = []
676
+ end
677
+
678
+ # ... generate_id, reset, generated_ids unchanged ...
679
+
680
+ private
681
+
682
+ # ... normalize_label unchanged ...
683
+
684
+ def add_hash_suffix(base_id, original_label)
685
+ hash_input = "#{original_label}_#{@generated_ids.count { |id| id.start_with?(base_id) }}"
686
+ hash = Digest::SHA256.hexdigest(hash_input)[0...HASH_SUFFIX_LENGTH]
687
+
688
+ max_base_length = max_length - HASH_SUFFIX_LENGTH - 1
689
+ truncated_base = base_id[0...max_base_length]
690
+
691
+ "#{truncated_base} #{hash}"
692
+ end
693
+
694
+ def truncate_to_limit(id)
695
+ return id if id.length <= max_length
696
+ id[0...max_length]
697
+ end
698
+ end
699
+ end
700
+ ```
701
+
702
+ - [ ] **Step 3: Update the test file's class references**
703
+
704
+ In `test/unit/id_generator_test.rb`, replace every `FlowChat::Whatsapp::IdGenerator` with `FlowChat::IdGenerator`, and add a test for the new cap:
705
+
706
+ ```ruby
707
+ def test_max_length_is_configurable
708
+ generator = FlowChat::IdGenerator.new(max_length: 10)
709
+
710
+ assert_equal 10, generator.generate_id("a" * 50).length
711
+ end
712
+
713
+ def test_default_max_length_is_unchanged
714
+ generator = FlowChat::IdGenerator.new
715
+
716
+ assert_equal 256, generator.generate_id("a" * 300).length
717
+ end
718
+ ```
719
+
720
+ - [ ] **Step 4: Update the WhatsApp choice mapper**
721
+
722
+ In `lib/flow_chat/whatsapp/middleware/choice_mapper.rb`, delete line 1 (`require_relative "../id_generator"`) since Zeitwerk resolves `FlowChat::IdGenerator`, and change the instantiation inside `create_id_mapping`:
723
+
724
+ ```ruby
725
+ id_generator = FlowChat::IdGenerator.new
726
+ ```
727
+
728
+ - [ ] **Step 5: Run the tests**
729
+
730
+ Run: `ruby -Itest test/unit/id_generator_test.rb`
731
+ Expected: PASS.
732
+
733
+ Run: `ruby -Itest test/unit/whatsapp/middleware/choice_mapper_test.rb`
734
+ Expected: PASS.
735
+
736
+ - [ ] **Step 6: Run the full suite**
737
+
738
+ Run: `bundle exec rake test`
739
+ Expected: PASS, 0 failures.
740
+
741
+ - [ ] **Step 7: Commit**
742
+
743
+ ```bash
744
+ git add -A lib/flow_chat/id_generator.rb lib/flow_chat/whatsapp/ test/unit/id_generator_test.rb
745
+ git commit -m "refactor(choices): lift the id generator out of whatsapp"
746
+ ```
747
+
748
+ ---
749
+
750
+ ## Task 5: Add plain-text markdown conversion
751
+
752
+ **Goal:** `to_plain_text` on `Renderers::MarkdownSupport`, for the two platforms that support no rich text at all.
753
+
754
+ **Files:**
755
+ - Modify: `lib/flow_chat/renderers/markdown_support.rb`
756
+ - Create: `test/unit/renderers/plain_text_support_test.rb`
757
+
758
+ **Acceptance Criteria:**
759
+ - [ ] Emphasis markers are removed, leaving the words: `**bold**` becomes `bold`
760
+ - [ ] `ul` renders as `• item` lines, `ol` as `1. item` lines
761
+ - [ ] A link renders as `text (url)`, and as bare `url` when the text equals the url
762
+ - [ ] HTML entities are decoded, and runs of 3 or more newlines collapse to 2
763
+
764
+ **Verify:** `ruby -Itest test/unit/renderers/plain_text_support_test.rb` → PASS
765
+
766
+ **Steps:**
767
+
768
+ - [ ] **Step 1: Write the failing test**
769
+
770
+ Create `test/unit/renderers/plain_text_support_test.rb`:
771
+
772
+ ```ruby
773
+ require "test_helper"
774
+
775
+ class PlainTextSupportTest < Minitest::Test
776
+ class Subject
777
+ include FlowChat::Renderers::MarkdownSupport
778
+ public :to_plain_text
779
+ end
780
+
781
+ def setup
782
+ @subject = Subject.new
783
+ end
784
+
785
+ def test_strips_emphasis
786
+ assert_equal "bold and italic", @subject.to_plain_text("**bold** and _italic_")
787
+ end
788
+
789
+ def test_unordered_list_becomes_bullets
790
+ assert_equal "• one\n• two", @subject.to_plain_text("- one\n- two")
791
+ end
792
+
793
+ def test_ordered_list_is_numbered
794
+ assert_equal "1. one\n2. two", @subject.to_plain_text("1. one\n2. two")
795
+ end
796
+
797
+ def test_link_shows_text_and_url
798
+ assert_equal "Docs (https://example.com)", @subject.to_plain_text("[Docs](https://example.com)")
799
+ end
800
+
801
+ def test_link_with_url_as_text_shows_url_once
802
+ assert_equal "https://example.com", @subject.to_plain_text("[https://example.com](https://example.com)")
803
+ end
804
+
805
+ def test_decodes_entities
806
+ assert_equal "Tom & Jerry", @subject.to_plain_text("Tom &amp; Jerry")
807
+ end
808
+
809
+ def test_nil_is_empty_string
810
+ assert_equal "", @subject.to_plain_text(nil)
811
+ end
812
+ end
813
+ ```
814
+
815
+ - [ ] **Step 2: Run it and watch it fail**
816
+
817
+ Run: `ruby -Itest test/unit/renderers/plain_text_support_test.rb`
818
+ Expected: FAIL with `NoMethodError: undefined method 'to_plain_text'`.
819
+
820
+ - [ ] **Step 3: Implement it**
821
+
822
+ Add to `lib/flow_chat/renderers/markdown_support.rb`, inside the module and above `private`:
823
+
824
+ ```ruby
825
+ # Markdown rendered as plain text, for platforms with no rich text at all.
826
+ # Messenger and Instagram both fall here: they display exactly the
827
+ # characters sent, so any leftover markup is noise the user reads.
828
+ def to_plain_text(text)
829
+ return "" if text.nil?
830
+
831
+ html = Kramdown::Document.new(text.to_s, **kramdown_options).to_html.strip
832
+ html_to_plain_text(html)
833
+ end
834
+ ```
835
+
836
+ And in the private section:
837
+
838
+ ```ruby
839
+ def html_to_plain_text(html)
840
+ result = html.dup
841
+
842
+ # Code blocks and inline code keep their content, lose their markers.
843
+ result.gsub!(%r{<pre[^>]*><code[^>]*>(.*?)</code></pre>}m) { $1.strip }
844
+ result.gsub!(%r{<code[^>]*>(.*?)</code>}m) { $1 }
845
+
846
+ # Links first: the anchor text is needed before tags are stripped.
847
+ result.gsub!(%r{<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)</a>}m) do
848
+ url, text = $1, $2
849
+ (text == url) ? url : "#{text} (#{url})"
850
+ end
851
+
852
+ result.gsub!(%r{<ul[^>]*>(.*?)</ul>}m) do
853
+ $1.scan(%r{<li[^>]*>(.*?)</li>}m).flatten.map { |item| "• #{item.strip}" }.join("\n")
854
+ end
855
+ result.gsub!(%r{<ol[^>]*>(.*?)</ol>}m) do
856
+ $1.scan(%r{<li[^>]*>(.*?)</li>}m).flatten.map.with_index(1) { |item, i| "#{i}. #{item.strip}" }.join("\n")
857
+ end
858
+
859
+ result.gsub!(%r{<blockquote[^>]*>(.*?)</blockquote>}m) do
860
+ $1.lines.map { |line| "> #{line.strip}" }.join("\n")
861
+ end
862
+
863
+ result.gsub!(%r{<p[^>]*>(.*?)</p>}m) { "#{$1}\n\n" }
864
+ result.gsub!(/<br\s*\/?>/, "\n")
865
+
866
+ # Every remaining tag, emphasis included, goes without replacement.
867
+ result.gsub!(/<[^>]+>/, "")
868
+
869
+ result.gsub!("&amp;", "&")
870
+ result.gsub!("&lt;", "<")
871
+ result.gsub!("&gt;", ">")
872
+ result.gsub!("&quot;", '"')
873
+ result.gsub!("&#39;", "'")
874
+ result.gsub!("&nbsp;", " ")
875
+
876
+ result.gsub!(/\n{3,}/, "\n\n")
877
+
878
+ result.strip
879
+ end
880
+ ```
881
+
882
+ - [ ] **Step 4: Run the test**
883
+
884
+ Run: `ruby -Itest test/unit/renderers/plain_text_support_test.rb`
885
+ Expected: PASS, 7 tests.
886
+
887
+ - [ ] **Step 5: Confirm nothing else regressed**
888
+
889
+ Run: `bundle exec rake test`
890
+ Expected: PASS. `to_html` and `to_whatsapp` are untouched.
891
+
892
+ - [ ] **Step 6: Commit**
893
+
894
+ ```bash
895
+ git add lib/flow_chat/renderers/markdown_support.rb test/unit/renderers/plain_text_support_test.rb
896
+ git commit -m "feat(renderers): render markdown as plain text"
897
+ ```
898
+
899
+ ---
900
+
901
+ ## Task 6: Fix WhatsApp lists above 10 choices
902
+
903
+ **Goal:** Stop building list payloads Meta rejects. Meta allows 10 rows for all sections combined, so above 10 choices the renderer falls back to a numbered body.
904
+
905
+ **Files:**
906
+ - Modify: `lib/flow_chat/whatsapp/renderer.rb:186-217` (`build_list_message`, `build_interactive_message`)
907
+ - Modify: `test/unit/whatsapp/renderer_test.rb:200-235` (the section-slicing assertions)
908
+ - Modify: `lib/flow_chat/whatsapp/middleware/choice_mapper.rb` (store a position map for the numbered rung)
909
+
910
+ **Acceptance Criteria:**
911
+ - [ ] 3 or fewer choices render `:interactive_buttons`, unchanged
912
+ - [ ] 4 to 10 choices render `:interactive_list` with exactly one section
913
+ - [ ] 11 or more choices render `:text` with each option numbered in the body, and no `sections` key
914
+ - [ ] No rendered list ever contains more than 10 rows in total
915
+ - [ ] Typing `"3"` on a numbered screen resolves to the third choice's original key
916
+
917
+ **Verify:** `ruby -Itest test/unit/whatsapp/renderer_test.rb && ruby -Itest test/unit/whatsapp/middleware/choice_mapper_test.rb` → PASS
918
+
919
+ **Steps:**
920
+
921
+ - [ ] **Step 1: Write the failing tests**
922
+
923
+ Replace the existing `test_list_message_pagination_for_many_choices` (around `test/unit/whatsapp/renderer_test.rb:200-214`) with:
924
+
925
+ ```ruby
926
+ # Meta allows "up to 10 sections, with up to 10 rows for all sections combined",
927
+ # so the old three-sections-of-ten payload was rejected on send.
928
+ def test_list_is_capped_at_ten_rows
929
+ choices = (1..10).to_h { |i| ["key#{i}", "Option #{i}"] }
930
+
931
+ result = FlowChat::Whatsapp::Renderer.new("Pick one", choices: choices).render
932
+
933
+ assert_equal :interactive_list, result[0]
934
+ assert_equal 1, result[2][:sections].length
935
+ assert_equal 10, result[2][:sections][0][:rows].length
936
+ end
937
+
938
+ def test_more_than_ten_choices_fall_back_to_a_numbered_body
939
+ choices = (1..25).to_h { |i| ["key#{i}", "Option #{i}"] }
940
+
941
+ result = FlowChat::Whatsapp::Renderer.new("Pick one", choices: choices).render
942
+
943
+ assert_equal :text, result[0]
944
+ assert_nil result[2][:sections]
945
+ assert_includes result[1], "1. Option 1"
946
+ assert_includes result[1], "25. Option 25"
947
+ assert_includes result[1], "Pick one"
948
+ end
949
+
950
+ def test_three_or_fewer_choices_still_use_buttons
951
+ choices = {"a" => "Alpha", "b" => "Beta"}
952
+
953
+ result = FlowChat::Whatsapp::Renderer.new("Pick one", choices: choices).render
954
+
955
+ assert_equal :interactive_buttons, result[0]
956
+ end
957
+ ```
958
+
959
+ - [ ] **Step 2: Run and watch them fail**
960
+
961
+ Run: `ruby -Itest test/unit/whatsapp/renderer_test.rb -n test_more_than_ten_choices_fall_back_to_a_numbered_body`
962
+ Expected: FAIL. It returns `:interactive_list` with 3 sections.
963
+
964
+ - [ ] **Step 3: Fix the renderer**
965
+
966
+ In `lib/flow_chat/whatsapp/renderer.rb`, add the cap constant to the class body:
967
+
968
+ ```ruby
969
+ # Meta: "up to 10 sections, with up to 10 rows for all sections combined".
970
+ MAX_LIST_ROWS = 10
971
+ MAX_BUTTONS = 3
972
+ ```
973
+
974
+ Change `build_interactive_message` to a three-way ladder:
975
+
976
+ ```ruby
977
+ def build_interactive_message(choice_hash)
978
+ if choice_hash.length <= MAX_BUTTONS
979
+ build_buttons_message(choice_hash)
980
+ elsif choice_hash.length <= MAX_LIST_ROWS
981
+ build_list_message(choice_hash)
982
+ else
983
+ build_numbered_message(choice_hash)
984
+ end
985
+ end
986
+ ```
987
+
988
+ Replace the section-slicing branch in `build_list_message` with a single section, and add the fallback:
989
+
990
+ ```ruby
991
+ def build_list_message(choices)
992
+ items = choices.map do |key, value|
993
+ original_text = value.to_s
994
+ truncated_title = truncate_text(original_text, 24)
995
+
996
+ description = if original_text.length > 24
997
+ truncate_text(original_text, 72)
998
+ end
999
+
1000
+ {
1001
+ id: key.to_s,
1002
+ title: truncated_title,
1003
+ description: description
1004
+ }.compact
1005
+ end
1006
+
1007
+ [:interactive_list, formatted_message, {sections: [{title: "Options", rows: items}]}]
1008
+ end
1009
+
1010
+ # Above the row cap there is no interactive surface left, so the options go
1011
+ # in the body and the user types a number. The choice mapper stores the
1012
+ # positions for this rung so the digit resolves to the original key.
1013
+ def build_numbered_message(choices)
1014
+ numbered = choices.values.map.with_index(1) { |label, i| "#{i}. #{label}" }.join("\n")
1015
+
1016
+ [:text, "#{formatted_message}\n\n#{numbered}", {}]
1017
+ end
1018
+ ```
1019
+
1020
+ - [ ] **Step 4: Run the renderer tests**
1021
+
1022
+ Run: `ruby -Itest test/unit/whatsapp/renderer_test.rb`
1023
+ Expected: PASS. If any other test asserted multi-section output, update it to the new ladder rather than restoring the old behavior.
1024
+
1025
+ - [ ] **Step 5: Teach the choice mapper the numbered rung**
1026
+
1027
+ In `lib/flow_chat/whatsapp/middleware/choice_mapper.rb`, store a position map alongside the id map whenever the numbered rung will be used, and resolve ids before positions. Replace `create_id_mapping` and add the resolution fallback:
1028
+
1029
+ ```ruby
1030
+ def create_id_mapping(choices)
1031
+ id_generator = FlowChat::IdGenerator.new
1032
+ id_choices = {}
1033
+ choice_mapping = {}
1034
+
1035
+ choices.each do |key, value|
1036
+ generated_id = id_generator.generate_id(value.to_s)
1037
+ id_choices[generated_id] = value
1038
+ choice_mapping[generated_id] = key.to_s
1039
+ end
1040
+
1041
+ store_choice_mapping(choice_mapping)
1042
+
1043
+ # Above the row cap the renderer numbers the options in the body, so
1044
+ # the reply is a digit rather than a row id.
1045
+ if choices.length > FlowChat::Whatsapp::Renderer::MAX_LIST_ROWS
1046
+ store_position_mapping(choices.keys.map.with_index(1) { |key, i| [i.to_s, key.to_s] }.to_h)
1047
+ else
1048
+ clear_position_mapping
1049
+ end
1050
+
1051
+ id_choices
1052
+ end
1053
+
1054
+ def store_position_mapping(mapping)
1055
+ @session.set("whatsapp.position_mapping", mapping)
1056
+ end
1057
+
1058
+ def get_position_mapping
1059
+ @session.get("whatsapp.position_mapping") || {}
1060
+ end
1061
+
1062
+ def clear_position_mapping
1063
+ @session.delete("whatsapp.position_mapping")
1064
+ end
1065
+ ```
1066
+
1067
+ Then widen `intercept?` and `handle_choice_input` to consult both maps, ids first:
1068
+
1069
+ ```ruby
1070
+ def resolved_choice
1071
+ input = @context.input.to_s
1072
+ get_choice_mapping[input] || get_position_mapping[input]
1073
+ end
1074
+
1075
+ def intercept?
1076
+ @context.input.present? && resolved_choice.present?
1077
+ end
1078
+
1079
+ def handle_choice_input
1080
+ original_choice = resolved_choice
1081
+ FlowChat.logger.info { "Whatsapp::ChoiceMapper: Resolving choice input #{@context.input} to #{original_choice}" }
1082
+ @context.input = original_choice
1083
+ end
1084
+ ```
1085
+
1086
+ Ids are resolved before positions because the two key spaces can overlap: `IdGenerator#normalize_label` keeps `\w`, which includes digits, so a choice labelled `"1"` generates the id `"1"`.
1087
+
1088
+ - [ ] **Step 6: Test the numbered resolution**
1089
+
1090
+ Add to `test/unit/whatsapp/middleware/choice_mapper_test.rb`:
1091
+
1092
+ ```ruby
1093
+ def test_typed_number_resolves_on_the_numbered_rung
1094
+ choices = (1..25).to_h { |i| ["key#{i}", "Option #{i}"] }
1095
+ app = ->(context) { [:prompt, "Pick one", choices, nil] }
1096
+ mapper = FlowChat::Whatsapp::Middleware::ChoiceMapper.new(app)
1097
+
1098
+ context = build_choice_mapper_context(input: "")
1099
+ mapper.call(context)
1100
+
1101
+ second_turn = build_choice_mapper_context(input: "3", session: context.session)
1102
+ mapper.call(second_turn)
1103
+
1104
+ assert_equal "key3", second_turn.input
1105
+ end
1106
+ ```
1107
+
1108
+ Match the existing helper names in that file. If it builds contexts inline rather than through a helper, follow its established style instead of introducing `build_choice_mapper_context`.
1109
+
1110
+ Run: `ruby -Itest test/unit/whatsapp/middleware/choice_mapper_test.rb`
1111
+ Expected: PASS.
1112
+
1113
+ - [ ] **Step 7: Run the full suite**
1114
+
1115
+ Run: `bundle exec rake test`
1116
+ Expected: PASS, 0 failures.
1117
+
1118
+ - [ ] **Step 8: Commit**
1119
+
1120
+ ```bash
1121
+ git add lib/flow_chat/whatsapp/renderer.rb lib/flow_chat/whatsapp/middleware/choice_mapper.rb \
1122
+ test/unit/whatsapp/renderer_test.rb test/unit/whatsapp/middleware/choice_mapper_test.rb
1123
+ git commit -m "fix(whatsapp): stop building list payloads Meta rejects
1124
+
1125
+ Meta allows ten rows for all sections combined, not ten per section, so
1126
+ slicing twenty-five choices into three sections produced a payload that
1127
+ failed on send. The section titles read like pagination but nothing was
1128
+ paged: no second message, no stored offset.
1129
+
1130
+ Above ten choices the options now go in the body numbered, and the choice
1131
+ mapper stores their positions so a typed digit resolves. Ids are resolved
1132
+ before positions because a choice labelled \"1\" generates the id \"1\"."
1133
+ ```
1134
+
1135
+ ---
1136
+
1137
+ ## Task 7: Say who sent a WhatsApp echo
1138
+
1139
+ **Goal:** Coexistence echoes report whether our app, another app, or a human in the business inbox sent the message.
1140
+
1141
+ **Files:**
1142
+ - Modify: `lib/flow_chat/whatsapp/gateway/cloud_api.rb:309-323` (`handle_unmodelled_field`)
1143
+ - Modify: `test/unit/whatsapp/gateway/cloud_api_test.rb`
1144
+
1145
+ **Acceptance Criteria:**
1146
+ - [ ] `WEBHOOK_RECEIVED` for an echo field carries `echo_origin`
1147
+ - [ ] `echo_origin` is `:self` when the payload's `app_id` equals the configured `app_id`
1148
+ - [ ] `echo_origin` is `:other_app` when an `app_id` is present but different
1149
+ - [ ] `echo_origin` is `:human_agent` when no `app_id` is present
1150
+ - [ ] Non-echo fields are published exactly as before, with no `echo_origin` key
1151
+
1152
+ **Verify:** `ruby -Itest test/unit/whatsapp/gateway/cloud_api_test.rb -n /echo/` → PASS
1153
+
1154
+ **Steps:**
1155
+
1156
+ - [ ] **Step 1: Write the failing tests**
1157
+
1158
+ Add to `test/unit/whatsapp/gateway/cloud_api_test.rb`:
1159
+
1160
+ ```ruby
1161
+ def test_echo_from_a_human_in_the_business_inbox
1162
+ @mock_config.app_id = "our_app"
1163
+ events = capture_events(FlowChat::Instrumentation::Events::WEBHOOK_RECEIVED) do
1164
+ context = create_context_with_request(
1165
+ method: :post,
1166
+ body: echo_payload(app_id: nil)
1167
+ )
1168
+ @gateway.call(context)
1169
+ end
1170
+
1171
+ assert_equal 1, events.size
1172
+ assert_equal :human_agent, events.first[:echo_origin]
1173
+ end
1174
+
1175
+ def test_echo_from_our_own_app
1176
+ @mock_config.app_id = "our_app"
1177
+ events = capture_events(FlowChat::Instrumentation::Events::WEBHOOK_RECEIVED) do
1178
+ context = create_context_with_request(method: :post, body: echo_payload(app_id: "our_app"))
1179
+ @gateway.call(context)
1180
+ end
1181
+
1182
+ assert_equal :self, events.first[:echo_origin]
1183
+ end
1184
+
1185
+ def test_echo_from_another_app
1186
+ @mock_config.app_id = "our_app"
1187
+ events = capture_events(FlowChat::Instrumentation::Events::WEBHOOK_RECEIVED) do
1188
+ context = create_context_with_request(method: :post, body: echo_payload(app_id: "someone_else"))
1189
+ @gateway.call(context)
1190
+ end
1191
+
1192
+ assert_equal :other_app, events.first[:echo_origin]
1193
+ end
1194
+
1195
+ def test_non_echo_field_has_no_echo_origin
1196
+ events = capture_events(FlowChat::Instrumentation::Events::WEBHOOK_RECEIVED) do
1197
+ context = create_context_with_request(
1198
+ method: :post,
1199
+ body: {"entry" => [{"id" => "biz_1", "changes" => [{"field" => "account_update", "value" => {"event" => "PARTNER_ADDED"}}]}]}
1200
+ )
1201
+ @gateway.call(context)
1202
+ end
1203
+
1204
+ refute events.first.key?(:echo_origin)
1205
+ end
1206
+
1207
+ private
1208
+
1209
+ def echo_payload(app_id:)
1210
+ value = {
1211
+ "metadata" => {"display_phone_number" => "+15551234567", "phone_number_id" => "test_phone_id"},
1212
+ "message_echoes" => [{"id" => "wamid.echo1", "from" => "15551234567", "type" => "text", "text" => {"body" => "Hi"}}]
1213
+ }
1214
+ value["message_echoes"][0]["app_id"] = app_id if app_id
1215
+
1216
+ {"entry" => [{"id" => "biz_1", "changes" => [{"field" => "smb_message_echoes", "value" => value}]}]}
1217
+ end
1218
+ ```
1219
+
1220
+ Use the file's existing event-capture helper. If none exists, add one that subscribes with `ActiveSupport::Notifications.subscribe`, collects payloads, and unsubscribes in `teardown` (the file already tracks `@subscribers` for this).
1221
+
1222
+ - [ ] **Step 2: Run and watch them fail**
1223
+
1224
+ Run: `ruby -Itest test/unit/whatsapp/gateway/cloud_api_test.rb -n /echo/`
1225
+ Expected: FAIL. `echo_origin` is not in the payload.
1226
+
1227
+ - [ ] **Step 3: Implement the derivation**
1228
+
1229
+ In `lib/flow_chat/whatsapp/gateway/cloud_api.rb`, extend `handle_unmodelled_field`:
1230
+
1231
+ ```ruby
1232
+ def handle_unmodelled_field(field, value, business_account_id)
1233
+ FlowChat.logger.info {
1234
+ "CloudApi: Publishing webhook field '#{field}' (value keys: #{value.keys.join(", ")})"
1235
+ }
1236
+
1237
+ payload = {
1238
+ platform: :whatsapp,
1239
+ gateway: :whatsapp_cloud_api,
1240
+ field: field,
1241
+ business_account_id: business_account_id,
1242
+ business_phone_number: value.dig("metadata", "display_phone_number"),
1243
+ business_phone_number_id: value.dig("metadata", "phone_number_id"),
1244
+ value: value
1245
+ }
1246
+
1247
+ origin = echo_origin(field, value)
1248
+ payload[:echo_origin] = origin if origin
1249
+
1250
+ instrument(Events::WEBHOOK_RECEIVED, payload)
1251
+ end
1252
+
1253
+ # An echo reports a message sent on the thread by someone other than the
1254
+ # person we are talking to. Which someone matters: a human replying from
1255
+ # the business inbox usually means the application should stop the flow,
1256
+ # while our own send coming back means nothing at all. Only the app_id
1257
+ # separates them, and only this gateway knows our own.
1258
+ def echo_origin(field, value)
1259
+ return nil unless field.to_s.include?("echo")
1260
+
1261
+ echoes = value.values.find { |v| v.is_a?(Array) && v.first.is_a?(Hash) }
1262
+ app_id = echoes&.first&.dig("app_id")
1263
+
1264
+ return :human_agent if app_id.blank?
1265
+ return :self if app_id.to_s == @config.app_id.to_s
1266
+
1267
+ :other_app
1268
+ end
1269
+ ```
1270
+
1271
+ - [ ] **Step 4: Run the tests**
1272
+
1273
+ Run: `ruby -Itest test/unit/whatsapp/gateway/cloud_api_test.rb`
1274
+ Expected: PASS, 4 new tests green.
1275
+
1276
+ - [ ] **Step 5: Run the full suite**
1277
+
1278
+ Run: `bundle exec rake test`
1279
+ Expected: PASS, 0 failures.
1280
+
1281
+ - [ ] **Step 6: Commit**
1282
+
1283
+ ```bash
1284
+ git add lib/flow_chat/whatsapp/gateway/cloud_api.rb test/unit/whatsapp/gateway/cloud_api_test.rb
1285
+ git commit -m "feat(whatsapp): say who sent an echo
1286
+
1287
+ An echo carrying no app_id is a human replying from the business inbox,
1288
+ which usually means the application wants the flow to stand down. One
1289
+ carrying our own app_id is just our send coming back. Only this gateway
1290
+ knows our app_id, so it derives the origin rather than leaving every
1291
+ subscriber to compare ids itself."
1292
+ ```
1293
+
1294
+ ---
1295
+
1296
+ # Phase 2: Messenger
1297
+
1298
+ ## Task 8: Messenger configuration
1299
+
1300
+ **Goal:** Credentials for Messenger, loadable from Rails credentials or environment, with the platform's limits as named constants.
1301
+
1302
+ **Files:**
1303
+ - Create: `lib/flow_chat/messenger/configuration.rb`
1304
+ - Create: `test/unit/messenger/configuration_test.rb`
1305
+ - Modify: `lib/flow_chat/config.rb` (add `Config.messenger`)
1306
+
1307
+ **Acceptance Criteria:**
1308
+ - [ ] `FlowChat::Messenger::Configuration` carries `page_id`, `access_token`, `verify_token`, `app_id`, `app_secret`, `skip_signature_validation`
1309
+ - [ ] `from_credentials` reads `messenger:` from Rails credentials, falling back to `MESSENGER_*` env vars
1310
+ - [ ] `valid?` is true only with `access_token`, `page_id` and `verify_token` all present
1311
+ - [ ] `messages_url` is `"#{api_base_url}/#{page_id}/messages"`
1312
+ - [ ] `FlowChat::Config.messenger` exposes `api_base_url`, `max_text_length`, `max_quick_replies`, `max_carousel_elements`, `max_buttons_per_element`
1313
+
1314
+ **Verify:** `ruby -Itest test/unit/messenger/configuration_test.rb` → PASS
1315
+
1316
+ **Steps:**
1317
+
1318
+ - [ ] **Step 1: Write the failing test**
1319
+
1320
+ Create `test/unit/messenger/configuration_test.rb`:
1321
+
1322
+ ```ruby
1323
+ require "test_helper"
1324
+
1325
+ class MessengerConfigurationTest < Minitest::Test
1326
+ def teardown
1327
+ FlowChat::Messenger::Configuration.clear_all!
1328
+ end
1329
+
1330
+ def test_valid_requires_token_page_and_verify_token
1331
+ config = FlowChat::Messenger::Configuration.new(nil)
1332
+ refute config.valid?
1333
+
1334
+ config.access_token = "tok"
1335
+ config.page_id = "page_1"
1336
+ refute config.valid?, "verify_token is still missing"
1337
+
1338
+ config.verify_token = "verify"
1339
+ assert config.valid?
1340
+ end
1341
+
1342
+ def test_messages_url_uses_the_page_id
1343
+ config = FlowChat::Messenger::Configuration.new(nil)
1344
+ config.page_id = "page_1"
1345
+
1346
+ assert_equal "#{FlowChat::Config.messenger.api_base_url}/page_1/messages", config.messages_url
1347
+ end
1348
+
1349
+ def test_from_credentials_reads_environment
1350
+ ENV["MESSENGER_ACCESS_TOKEN"] = "env_token"
1351
+ ENV["MESSENGER_PAGE_ID"] = "env_page"
1352
+ ENV["MESSENGER_VERIFY_TOKEN"] = "env_verify"
1353
+ ENV["MESSENGER_APP_SECRET"] = "env_secret"
1354
+
1355
+ config = FlowChat::Messenger::Configuration.from_credentials
1356
+
1357
+ assert_equal "env_token", config.access_token
1358
+ assert_equal "env_page", config.page_id
1359
+ assert_equal "env_secret", config.app_secret
1360
+ assert config.valid?
1361
+ ensure
1362
+ %w[MESSENGER_ACCESS_TOKEN MESSENGER_PAGE_ID MESSENGER_VERIFY_TOKEN MESSENGER_APP_SECRET].each { |k| ENV.delete(k) }
1363
+ end
1364
+
1365
+ def test_registers_by_name
1366
+ config = FlowChat::Messenger::Configuration.new(:acme)
1367
+
1368
+ assert_same config, FlowChat::Messenger::Configuration.get(:acme)
1369
+ end
1370
+ end
1371
+ ```
1372
+
1373
+ - [ ] **Step 2: Run and watch it fail**
1374
+
1375
+ Run: `ruby -Itest test/unit/messenger/configuration_test.rb`
1376
+ Expected: FAIL with `NameError: uninitialized constant FlowChat::Messenger`.
1377
+
1378
+ - [ ] **Step 3: Add the global config object**
1379
+
1380
+ In `lib/flow_chat/config.rb`, add the accessor next to `self.whatsapp`:
1381
+
1382
+ ```ruby
1383
+ # Messenger-specific configuration object
1384
+ def self.messenger
1385
+ @messenger ||= MessengerConfig.new
1386
+ end
1387
+ ```
1388
+
1389
+ And the class next to `WhatsappConfig`:
1390
+
1391
+ ```ruby
1392
+ class MessengerConfig
1393
+ attr_reader :api_base_url, :max_text_length, :max_quick_replies,
1394
+ :max_carousel_elements, :max_buttons_per_element,
1395
+ :max_quick_reply_title, :max_button_title, :max_element_title
1396
+
1397
+ def initialize
1398
+ @api_base_url = "https://graph.facebook.com/v23.0"
1399
+ @max_text_length = 2000
1400
+ @max_quick_replies = 13
1401
+ @max_quick_reply_title = 20
1402
+ @max_carousel_elements = 10
1403
+ @max_buttons_per_element = 3
1404
+ @max_button_title = 20
1405
+ @max_element_title = 80
1406
+ end
1407
+ end
1408
+ ```
1409
+
1410
+ - [ ] **Step 4: Write the configuration class**
1411
+
1412
+ Create `lib/flow_chat/messenger/configuration.rb`:
1413
+
1414
+ ```ruby
1415
+ module FlowChat
1416
+ module Messenger
1417
+ class ConfigurationError < StandardError; end
1418
+
1419
+ class Configuration
1420
+ include FlowChat::NamedConfiguration
1421
+
1422
+ attr_accessor :access_token, :page_id, :verify_token, :app_id, :app_secret,
1423
+ :name, :skip_signature_validation
1424
+
1425
+ def initialize(name)
1426
+ @name = name
1427
+ @skip_signature_validation = false
1428
+
1429
+ FlowChat.logger.debug { "Messenger::Configuration: Initialized configuration with name: #{name || "anonymous"}" }
1430
+
1431
+ register_as(name) if name.present?
1432
+ end
1433
+
1434
+ def self.from_credentials
1435
+ FlowChat.logger.info { "Messenger::Configuration: Loading configuration from credentials/environment" }
1436
+
1437
+ config = new(nil)
1438
+
1439
+ if defined?(Rails) && Rails.respond_to?(:application) && Rails.application&.credentials&.messenger
1440
+ credentials = Rails.application.credentials.messenger
1441
+ config.access_token = credentials[:access_token]
1442
+ config.page_id = credentials[:page_id]
1443
+ config.verify_token = credentials[:verify_token]
1444
+ config.app_id = credentials[:app_id]
1445
+ config.app_secret = credentials[:app_secret]
1446
+ config.skip_signature_validation = credentials[:skip_signature_validation] || false
1447
+ else
1448
+ config.access_token = ENV["MESSENGER_ACCESS_TOKEN"]
1449
+ config.page_id = ENV["MESSENGER_PAGE_ID"]
1450
+ config.verify_token = ENV["MESSENGER_VERIFY_TOKEN"]
1451
+ config.app_id = ENV["MESSENGER_APP_ID"]
1452
+ config.app_secret = ENV["MESSENGER_APP_SECRET"]
1453
+ config.skip_signature_validation = ENV["MESSENGER_SKIP_SIGNATURE_VALIDATION"] == "true"
1454
+ end
1455
+
1456
+ config
1457
+ end
1458
+
1459
+ def valid?
1460
+ access_token.present? && page_id.present? && verify_token.present?
1461
+ end
1462
+
1463
+ # The account this configuration speaks for. Named generically so the
1464
+ # shared gateway can check it without knowing which platform it holds.
1465
+ def account_id
1466
+ page_id
1467
+ end
1468
+
1469
+ def messages_url
1470
+ "#{api_base_url}/#{page_id}/messages"
1471
+ end
1472
+
1473
+ def attachment_upload_url
1474
+ "#{api_base_url}/#{page_id}/message_attachments"
1475
+ end
1476
+
1477
+ def api_base_url
1478
+ FlowChat::Config.messenger.api_base_url
1479
+ end
1480
+
1481
+ def api_headers
1482
+ {
1483
+ "Authorization" => "Bearer #{access_token}",
1484
+ "Content-Type" => "application/json"
1485
+ }
1486
+ end
1487
+ end
1488
+ end
1489
+ end
1490
+ ```
1491
+
1492
+ - [ ] **Step 5: Run the test**
1493
+
1494
+ Run: `ruby -Itest test/unit/messenger/configuration_test.rb`
1495
+ Expected: PASS, 4 tests.
1496
+
1497
+ - [ ] **Step 6: Commit**
1498
+
1499
+ ```bash
1500
+ git add lib/flow_chat/messenger/configuration.rb lib/flow_chat/config.rb test/unit/messenger/configuration_test.rb
1501
+ git commit -m "feat(messenger): configure the page a gateway speaks for"
1502
+ ```
1503
+
1504
+ ---
1505
+
1506
+ ## Task 9: The choice ladder helper
1507
+
1508
+ **Goal:** One place that decides which rung renders a given choice count, so the renderer and the choice mapper cannot disagree.
1509
+
1510
+ **Files:**
1511
+ - Create: `lib/flow_chat/meta/choice_ladder.rb`
1512
+ - Create: `test/unit/meta/choice_ladder_test.rb`
1513
+
1514
+ **Acceptance Criteria:**
1515
+ - [ ] `rung_for(count, limits)` returns `:quick_replies`, `:carousel` or `:numbered`
1516
+ - [ ] `count` of 0 returns `:none`
1517
+ - [ ] With Messenger limits: 13 is `:quick_replies`, 14 is `:carousel`, 30 is `:carousel`, 31 is `:numbered`
1518
+ - [ ] `numbers_in_body?` is true on the `:numbered` rung, and true on every rung when `always_number:` is set
1519
+
1520
+ **Verify:** `ruby -Itest test/unit/meta/choice_ladder_test.rb` → PASS
1521
+
1522
+ **Steps:**
1523
+
1524
+ - [ ] **Step 1: Write the failing test**
1525
+
1526
+ Create `test/unit/meta/choice_ladder_test.rb`:
1527
+
1528
+ ```ruby
1529
+ require "test_helper"
1530
+
1531
+ class ChoiceLadderTest < Minitest::Test
1532
+ def setup
1533
+ @limits = FlowChat::Config.messenger
1534
+ end
1535
+
1536
+ def test_no_choices
1537
+ assert_equal :none, FlowChat::Meta::ChoiceLadder.rung_for(0, @limits)
1538
+ end
1539
+
1540
+ def test_quick_replies_up_to_thirteen
1541
+ assert_equal :quick_replies, FlowChat::Meta::ChoiceLadder.rung_for(1, @limits)
1542
+ assert_equal :quick_replies, FlowChat::Meta::ChoiceLadder.rung_for(13, @limits)
1543
+ end
1544
+
1545
+ def test_carousel_between_fourteen_and_thirty
1546
+ assert_equal :carousel, FlowChat::Meta::ChoiceLadder.rung_for(14, @limits)
1547
+ assert_equal :carousel, FlowChat::Meta::ChoiceLadder.rung_for(30, @limits)
1548
+ end
1549
+
1550
+ def test_numbered_above_the_carousel_capacity
1551
+ assert_equal :numbered, FlowChat::Meta::ChoiceLadder.rung_for(31, @limits)
1552
+ assert_equal :numbered, FlowChat::Meta::ChoiceLadder.rung_for(200, @limits)
1553
+ end
1554
+
1555
+ def test_numbers_in_body_on_the_numbered_rung
1556
+ assert FlowChat::Meta::ChoiceLadder.numbers_in_body?(31, @limits)
1557
+ refute FlowChat::Meta::ChoiceLadder.numbers_in_body?(5, @limits)
1558
+ end
1559
+
1560
+ # Instagram renders quick replies and carousels on mobile only, so its
1561
+ # renderer numbers the body at every rung.
1562
+ def test_always_number_covers_every_rung
1563
+ assert FlowChat::Meta::ChoiceLadder.numbers_in_body?(5, @limits, always_number: true)
1564
+ assert FlowChat::Meta::ChoiceLadder.numbers_in_body?(20, @limits, always_number: true)
1565
+ refute FlowChat::Meta::ChoiceLadder.numbers_in_body?(0, @limits, always_number: true)
1566
+ end
1567
+ end
1568
+ ```
1569
+
1570
+ - [ ] **Step 2: Run and watch it fail**
1571
+
1572
+ Run: `ruby -Itest test/unit/meta/choice_ladder_test.rb`
1573
+ Expected: FAIL with `NameError: uninitialized constant FlowChat::Meta::ChoiceLadder`.
1574
+
1575
+ - [ ] **Step 3: Implement it**
1576
+
1577
+ Create `lib/flow_chat/meta/choice_ladder.rb`:
1578
+
1579
+ ```ruby
1580
+ module FlowChat
1581
+ module Meta
1582
+ # Which interactive surface renders a given number of choices.
1583
+ #
1584
+ # The renderer and the choice mapper both need this answer, and they must
1585
+ # agree: the renderer decides what the user sees, the mapper decides what a
1586
+ # reply is allowed to mean. Two copies of the arithmetic would drift into a
1587
+ # screen whose replies cannot be resolved.
1588
+ module ChoiceLadder
1589
+ def self.rung_for(count, limits)
1590
+ return :none if count.zero?
1591
+ return :quick_replies if count <= limits.max_quick_replies
1592
+ return :carousel if count <= carousel_capacity(limits)
1593
+
1594
+ :numbered
1595
+ end
1596
+
1597
+ # The carousel holds elements, each holding buttons, and one option is one
1598
+ # button.
1599
+ def self.carousel_capacity(limits)
1600
+ limits.max_carousel_elements * limits.max_buttons_per_element
1601
+ end
1602
+
1603
+ # Whether the options are also listed, numbered, in the message body.
1604
+ #
1605
+ # always_number is for platforms whose interactive surfaces do not render
1606
+ # everywhere. Without it a user who cannot see the buttons has no way to
1607
+ # answer at all.
1608
+ def self.numbers_in_body?(count, limits, always_number: false)
1609
+ return false if count.zero?
1610
+ return true if always_number
1611
+
1612
+ rung_for(count, limits) == :numbered
1613
+ end
1614
+ end
1615
+ end
1616
+ end
1617
+ ```
1618
+
1619
+ - [ ] **Step 4: Run the test**
1620
+
1621
+ Run: `ruby -Itest test/unit/meta/choice_ladder_test.rb`
1622
+ Expected: PASS, 6 tests.
1623
+
1624
+ - [ ] **Step 5: Commit**
1625
+
1626
+ ```bash
1627
+ git add lib/flow_chat/meta/choice_ladder.rb test/unit/meta/choice_ladder_test.rb
1628
+ git commit -m "feat(meta): decide the choice rung in one place"
1629
+ ```
1630
+
1631
+ ---
1632
+
1633
+ ## Task 10: Messenger renderer
1634
+
1635
+ **Goal:** Turn `[prompt, choices, media]` into a Send API payload shape, down the ladder, in plain text.
1636
+
1637
+ **Files:**
1638
+ - Create: `lib/flow_chat/messenger/renderer.rb`
1639
+ - Create: `test/unit/messenger/renderer_test.rb`
1640
+
1641
+ **Acceptance Criteria:**
1642
+ - [ ] No choices renders `[:text, plain_text, {}]`
1643
+ - [ ] 1 to 13 choices render `[:quick_replies, text, {quick_replies: [{content_type:, title:, payload:}]}]` with titles truncated to 20
1644
+ - [ ] 14 to 30 choices render `[:carousel, text, {elements: [...]}]` with at most 10 elements of at most 3 `postback` buttons
1645
+ - [ ] Above 30 choices renders `[:text, text_with_numbered_options, {}]`
1646
+ - [ ] Markdown in the prompt is flattened: `**bold**` arrives as `bold`
1647
+ - [ ] Media with no choices renders `[:attachment, caption, {type:, url:}]`
1648
+
1649
+ **Verify:** `ruby -Itest test/unit/messenger/renderer_test.rb` → PASS
1650
+
1651
+ **Steps:**
1652
+
1653
+ - [ ] **Step 1: Write the failing test**
1654
+
1655
+ Create `test/unit/messenger/renderer_test.rb`:
1656
+
1657
+ ```ruby
1658
+ require "test_helper"
1659
+
1660
+ class MessengerRendererTest < Minitest::Test
1661
+ def render(message, choices: nil, media: nil)
1662
+ FlowChat::Messenger::Renderer.new(message, choices: choices, media: media).render
1663
+ end
1664
+
1665
+ def test_plain_text_message
1666
+ result = render("Hello **world**")
1667
+
1668
+ assert_equal :text, result[0]
1669
+ assert_equal "Hello world", result[1]
1670
+ assert_equal({}, result[2])
1671
+ end
1672
+
1673
+ def test_quick_replies_for_thirteen_or_fewer
1674
+ choices = (1..13).to_h { |i| ["k#{i}", "Option #{i}"] }
1675
+
1676
+ result = render("Pick one", choices: choices)
1677
+
1678
+ assert_equal :quick_replies, result[0]
1679
+ assert_equal 13, result[2][:quick_replies].length
1680
+ assert_equal "text", result[2][:quick_replies][0][:content_type]
1681
+ assert_equal "Option 1", result[2][:quick_replies][0][:title]
1682
+ assert_equal "k1", result[2][:quick_replies][0][:payload]
1683
+ end
1684
+
1685
+ def test_quick_reply_titles_truncate_at_twenty
1686
+ result = render("Pick", choices: {"k" => "A title that is definitely longer than twenty"})
1687
+
1688
+ assert_equal 20, result[2][:quick_replies][0][:title].length
1689
+ end
1690
+
1691
+ def test_carousel_between_fourteen_and_thirty
1692
+ choices = (1..14).to_h { |i| ["k#{i}", "Option #{i}"] }
1693
+
1694
+ result = render("Pick one", choices: choices)
1695
+
1696
+ assert_equal :carousel, result[0]
1697
+ assert_equal 5, result[2][:elements].length
1698
+ assert_equal 3, result[2][:elements][0][:buttons].length
1699
+ assert_equal "postback", result[2][:elements][0][:buttons][0][:type]
1700
+ assert_equal "k1", result[2][:elements][0][:buttons][0][:payload]
1701
+ end
1702
+
1703
+ def test_carousel_never_exceeds_ten_elements
1704
+ choices = (1..30).to_h { |i| ["k#{i}", "Option #{i}"] }
1705
+
1706
+ result = render("Pick one", choices: choices)
1707
+
1708
+ assert_equal :carousel, result[0]
1709
+ assert_equal 10, result[2][:elements].length
1710
+ assert_equal 30, result[2][:elements].sum { |e| e[:buttons].length }
1711
+ end
1712
+
1713
+ def test_numbered_body_above_thirty
1714
+ choices = (1..31).to_h { |i| ["k#{i}", "Option #{i}"] }
1715
+
1716
+ result = render("Pick one", choices: choices)
1717
+
1718
+ assert_equal :text, result[0]
1719
+ assert_includes result[1], "1. Option 1"
1720
+ assert_includes result[1], "31. Option 31"
1721
+ end
1722
+
1723
+ def test_attachment_without_choices
1724
+ result = render("A caption", media: {type: :image, url: "https://example.com/a.png"})
1725
+
1726
+ assert_equal :attachment, result[0]
1727
+ assert_equal "A caption", result[1]
1728
+ assert_equal :image, result[2][:type]
1729
+ assert_equal "https://example.com/a.png", result[2][:url]
1730
+ end
1731
+ end
1732
+ ```
1733
+
1734
+ - [ ] **Step 2: Run and watch it fail**
1735
+
1736
+ Run: `ruby -Itest test/unit/messenger/renderer_test.rb`
1737
+ Expected: FAIL with `NameError: uninitialized constant FlowChat::Messenger::Renderer`.
1738
+
1739
+ - [ ] **Step 3: Implement the renderer**
1740
+
1741
+ Create `lib/flow_chat/messenger/renderer.rb`:
1742
+
1743
+ ```ruby
1744
+ require "flow_chat/renderers/markdown_support"
1745
+
1746
+ module FlowChat
1747
+ module Messenger
1748
+ class Renderer
1749
+ include FlowChat::Renderers::MarkdownSupport
1750
+
1751
+ attr_reader :message, :choices, :media
1752
+
1753
+ def initialize(message, choices: nil, media: nil)
1754
+ @message = message
1755
+ @choices = choices
1756
+ @media = media
1757
+ end
1758
+
1759
+ def render
1760
+ return build_attachment if media && choices.blank?
1761
+
1762
+ case FlowChat::Meta::ChoiceLadder.rung_for(choice_count, limits)
1763
+ when :none then build_text
1764
+ when :quick_replies then build_quick_replies
1765
+ when :carousel then build_carousel
1766
+ when :numbered then build_numbered
1767
+ end
1768
+ end
1769
+
1770
+ private
1771
+
1772
+ def limits
1773
+ FlowChat::Config.messenger
1774
+ end
1775
+
1776
+ def always_number?
1777
+ false
1778
+ end
1779
+
1780
+ def choice_count
1781
+ choices.is_a?(Hash) ? choices.length : 0
1782
+ end
1783
+
1784
+ # Neither Messenger nor Instagram renders markup, so the prompt is
1785
+ # flattened rather than translated.
1786
+ def body
1787
+ text = to_plain_text(message)
1788
+ return text unless FlowChat::Meta::ChoiceLadder.numbers_in_body?(choice_count, limits, always_number: always_number?)
1789
+
1790
+ "#{text}\n\n#{numbered_options}"
1791
+ end
1792
+
1793
+ def numbered_options
1794
+ choices.values.map.with_index(1) { |label, i| "#{i}. #{label}" }.join("\n")
1795
+ end
1796
+
1797
+ def build_text
1798
+ [:text, body, {}]
1799
+ end
1800
+
1801
+ def build_numbered
1802
+ [:text, body, {}]
1803
+ end
1804
+
1805
+ def build_quick_replies
1806
+ replies = choices.map do |key, label|
1807
+ {
1808
+ content_type: "text",
1809
+ title: truncate_text(label.to_s, limits.max_quick_reply_title),
1810
+ payload: key.to_s
1811
+ }
1812
+ end
1813
+
1814
+ [:quick_replies, body, {quick_replies: replies}]
1815
+ end
1816
+
1817
+ # One option is one button, and buttons live on elements, so the options
1818
+ # are packed across elements rather than one element per option.
1819
+ def build_carousel
1820
+ elements = choices.each_slice(limits.max_buttons_per_element).map.with_index(1) do |slice, index|
1821
+ first = (index - 1) * limits.max_buttons_per_element + 1
1822
+ last = first + slice.length - 1
1823
+
1824
+ {
1825
+ title: truncate_text("Options #{first} to #{last}", limits.max_element_title),
1826
+ buttons: slice.map do |key, label|
1827
+ {
1828
+ type: "postback",
1829
+ title: truncate_text(label.to_s, limits.max_button_title),
1830
+ payload: key.to_s
1831
+ }
1832
+ end
1833
+ }
1834
+ end
1835
+
1836
+ [:carousel, body, {elements: elements}]
1837
+ end
1838
+
1839
+ def build_attachment
1840
+ type = (media[:type] || :image).to_sym
1841
+ options = {type: type}
1842
+ options[:url] = media[:url] if media[:url]
1843
+ options[:attachment_id] = media[:id] if media[:id]
1844
+
1845
+ [:attachment, to_plain_text(message), options]
1846
+ end
1847
+
1848
+ def truncate_text(text, length)
1849
+ return text if text.length <= length
1850
+ text[0, length - 3] + "..."
1851
+ end
1852
+ end
1853
+ end
1854
+ end
1855
+ ```
1856
+
1857
+ - [ ] **Step 4: Run the test**
1858
+
1859
+ Run: `ruby -Itest test/unit/messenger/renderer_test.rb`
1860
+ Expected: PASS, 8 tests.
1861
+
1862
+ - [ ] **Step 5: Commit**
1863
+
1864
+ ```bash
1865
+ git add lib/flow_chat/messenger/renderer.rb test/unit/messenger/renderer_test.rb
1866
+ git commit -m "feat(messenger): render prompts down the choice ladder"
1867
+ ```
1868
+
1869
+ ---
1870
+
1871
+ ## Task 11: Messenger client
1872
+
1873
+ **Goal:** Send to the Send API, splitting text that exceeds the platform cap, and report failures through the existing delivery hooks.
1874
+
1875
+ **Files:**
1876
+ - Create: `lib/flow_chat/messenger/client.rb`
1877
+ - Create: `test/unit/messenger/client_test.rb`
1878
+
1879
+ **Acceptance Criteria:**
1880
+ - [ ] `send_message(recipient_id, prompt, choices:, media:)` posts to `config.messages_url`
1881
+ - [ ] The payload is `{recipient: {id:}, messaging_type: "RESPONSE", message: {...}}`
1882
+ - [ ] Quick replies attach to the text message; a carousel posts an `attachment` with `template_type: "generic"`
1883
+ - [ ] Text longer than `max_text_length` is sent as several messages, split on whitespace, and the last result is returned
1884
+ - [ ] A non-2xx response reports `API_ERROR` and returns `nil`
1885
+ - [ ] `upload_media` posts to `config.attachment_upload_url` and returns the `attachment_id`
1886
+
1887
+ **Verify:** `ruby -Itest test/unit/messenger/client_test.rb` → PASS
1888
+
1889
+ **Steps:**
1890
+
1891
+ - [ ] **Step 1: Write the failing test**
1892
+
1893
+ Create `test/unit/messenger/client_test.rb`:
1894
+
1895
+ ```ruby
1896
+ require "test_helper"
1897
+ require "webmock/minitest"
1898
+
1899
+ class MessengerClientTest < Minitest::Test
1900
+ def setup
1901
+ @config = FlowChat::Messenger::Configuration.new(nil)
1902
+ @config.page_id = "page_1"
1903
+ @config.access_token = "tok"
1904
+ @config.verify_token = "verify"
1905
+ @client = FlowChat::Messenger::Client.new(@config)
1906
+
1907
+ WebMock.enable!
1908
+ WebMock.reset!
1909
+ stub_request(:post, @config.messages_url)
1910
+ .to_return(status: 200, body: {"recipient_id" => "psid_1", "message_id" => "mid.1"}.to_json)
1911
+ end
1912
+
1913
+ def teardown
1914
+ WebMock.disable!
1915
+ WebMock.reset!
1916
+ end
1917
+
1918
+ def test_sends_text_with_the_send_api_shape
1919
+ result = @client.send_message("psid_1", "Hello")
1920
+
1921
+ assert_equal "mid.1", result["message_id"]
1922
+ assert_requested(:post, @config.messages_url) do |req|
1923
+ body = JSON.parse(req.body)
1924
+ body["recipient"] == {"id" => "psid_1"} &&
1925
+ body["messaging_type"] == "RESPONSE" &&
1926
+ body["message"] == {"text" => "Hello"}
1927
+ end
1928
+ end
1929
+
1930
+ def test_quick_replies_ride_on_the_text_message
1931
+ @client.send_message("psid_1", "Pick", choices: {"a" => "Alpha", "b" => "Beta"})
1932
+
1933
+ assert_requested(:post, @config.messages_url) do |req|
1934
+ message = JSON.parse(req.body)["message"]
1935
+ message["text"] == "Pick" && message["quick_replies"].length == 2
1936
+ end
1937
+ end
1938
+
1939
+ def test_carousel_posts_a_generic_template
1940
+ choices = (1..14).to_h { |i| ["k#{i}", "Option #{i}"] }
1941
+
1942
+ @client.send_message("psid_1", "Pick", choices: choices)
1943
+
1944
+ assert_requested(:post, @config.messages_url) do |req|
1945
+ payload = JSON.parse(req.body).dig("message", "attachment", "payload")
1946
+ payload["template_type"] == "generic" && payload["elements"].length == 5
1947
+ end
1948
+ end
1949
+
1950
+ def test_long_text_is_split_into_several_sends
1951
+ long = "word " * 600 # comfortably over 2000 characters
1952
+
1953
+ @client.send_message("psid_1", long)
1954
+
1955
+ assert_requested(:post, @config.messages_url, times: 2)
1956
+ end
1957
+
1958
+ def test_failed_request_returns_nil
1959
+ WebMock.reset!
1960
+ stub_request(:post, @config.messages_url).to_return(status: 400, body: '{"error":{"message":"bad"}}')
1961
+
1962
+ assert_nil @client.send_message("psid_1", "Hello")
1963
+ end
1964
+ end
1965
+ ```
1966
+
1967
+ - [ ] **Step 2: Run and watch it fail**
1968
+
1969
+ Run: `ruby -Itest test/unit/messenger/client_test.rb`
1970
+ Expected: FAIL with `NameError: uninitialized constant FlowChat::Messenger::Client`.
1971
+
1972
+ - [ ] **Step 3: Implement the client**
1973
+
1974
+ Create `lib/flow_chat/messenger/client.rb`:
1975
+
1976
+ ```ruby
1977
+ require "net/http"
1978
+ require "json"
1979
+ require "uri"
1980
+
1981
+ module FlowChat
1982
+ module Messenger
1983
+ class Client
1984
+ include FlowChat::Instrumentation
1985
+
1986
+ def initialize(config)
1987
+ @config = config
1988
+ FlowChat.logger.info { "Messenger::Client: Initialized for page_id: #{@config.page_id}" }
1989
+ end
1990
+
1991
+ def send_message(recipient_id, prompt, choices: nil, media: nil)
1992
+ response = renderer_class.new(prompt, choices: choices, media: media).render
1993
+ type, content, options = response
1994
+
1995
+ instrument(Events::MESSAGE_SENT, {
1996
+ to: recipient_id,
1997
+ message_type: type.to_s,
1998
+ content_length: content.to_s.length,
1999
+ platform: platform
2000
+ }) do
2001
+ deliver(recipient_id, type, content, options)
2002
+ end
2003
+ end
2004
+
2005
+ def send_text(recipient_id, text)
2006
+ send_message(recipient_id, text)
2007
+ end
2008
+
2009
+ # Uploads a file for reuse and returns the id Meta assigned it.
2010
+ def upload_media(url, type: :image)
2011
+ payload = {
2012
+ message: {
2013
+ attachment: {
2014
+ type: type.to_s,
2015
+ payload: {url: url, is_reusable: true}
2016
+ }
2017
+ }
2018
+ }
2019
+
2020
+ result = post_json(@config.attachment_upload_url, payload)
2021
+ result && result["attachment_id"]
2022
+ end
2023
+
2024
+ private
2025
+
2026
+ def renderer_class
2027
+ FlowChat::Messenger::Renderer
2028
+ end
2029
+
2030
+ def platform
2031
+ :messenger
2032
+ end
2033
+
2034
+ def limits
2035
+ FlowChat::Config.messenger
2036
+ end
2037
+
2038
+ # Anything over the platform's cap is rejected whole rather than trimmed by
2039
+ # Meta, so long text goes as several messages. Only the last result is
2040
+ # returned: it carries the id of the message the user ends up looking at.
2041
+ def deliver(recipient_id, type, content, options)
2042
+ case type
2043
+ when :text
2044
+ split_text(content).map { |chunk| post_message(recipient_id, {text: chunk}) }.last
2045
+ when :quick_replies
2046
+ chunks = split_text(content)
2047
+ # Quick replies belong on the final chunk, next to the question.
2048
+ chunks[0..-2].each { |chunk| post_message(recipient_id, {text: chunk}) }
2049
+ post_message(recipient_id, {text: chunks.last, quick_replies: options[:quick_replies]})
2050
+ when :carousel
2051
+ post_message(recipient_id, {text: content}) if content.present?
2052
+ post_message(recipient_id, {
2053
+ attachment: {
2054
+ type: "template",
2055
+ payload: {template_type: "generic", elements: options[:elements]}
2056
+ }
2057
+ })
2058
+ when :attachment
2059
+ attachment_payload = options[:url] ? {url: options[:url], is_reusable: true} : {attachment_id: options[:attachment_id]}
2060
+ post_message(recipient_id, {text: content}) if content.present?
2061
+ post_message(recipient_id, {
2062
+ attachment: {type: options[:type].to_s, payload: attachment_payload}
2063
+ })
2064
+ end
2065
+ end
2066
+
2067
+ def post_message(recipient_id, message)
2068
+ post_json(@config.messages_url, {
2069
+ recipient: {id: recipient_id},
2070
+ messaging_type: "RESPONSE",
2071
+ message: message
2072
+ })
2073
+ end
2074
+
2075
+ # Splits on whitespace so a word is never cut in half. Measured with the
2076
+ # platform's own unit, which is bytes on Instagram and characters here.
2077
+ def split_text(text)
2078
+ limit = limits.max_text_length
2079
+ return [text.to_s] if measure(text.to_s) <= limit
2080
+
2081
+ chunks = []
2082
+ current = ""
2083
+
2084
+ text.to_s.split(/(\s+)/).each do |piece|
2085
+ if measure(current + piece) > limit && current.present?
2086
+ chunks << current.strip
2087
+ current = piece.lstrip
2088
+ else
2089
+ current += piece
2090
+ end
2091
+ end
2092
+
2093
+ chunks << current.strip if current.strip.present?
2094
+ chunks
2095
+ end
2096
+
2097
+ def measure(string)
2098
+ string.length
2099
+ end
2100
+
2101
+ def post_json(url, payload)
2102
+ uri = URI(url)
2103
+ http = Net::HTTP.new(uri.host, uri.port)
2104
+ http.use_ssl = true
2105
+
2106
+ request = Net::HTTP::Post.new(uri)
2107
+ @config.api_headers.each { |key, value| request[key] = value }
2108
+ request.body = payload.to_json
2109
+
2110
+ response = http.request(request)
2111
+
2112
+ if response.is_a?(Net::HTTPSuccess)
2113
+ JSON.parse(response.body)
2114
+ else
2115
+ FlowChat.logger.error { "#{self.class.name}: API request failed - #{response.code}: #{response.body}" }
2116
+ report_api_error(
2117
+ "#{platform} API request failed",
2118
+ response_code: response.code,
2119
+ response_body: response.body
2120
+ )
2121
+ nil
2122
+ end
2123
+ rescue Net::OpenTimeout, Net::ReadTimeout => network_error
2124
+ FlowChat.logger.error { "#{self.class.name}: Network timeout: #{network_error.class.name}" }
2125
+ raise network_error
2126
+ end
2127
+ end
2128
+ end
2129
+ end
2130
+ ```
2131
+
2132
+ Check `report_api_error`'s exact signature in `lib/flow_chat/instrumentation.rb` before wiring it, and match the keyword arguments the WhatsApp client passes at `whatsapp/client.rb:540`.
2133
+
2134
+ - [ ] **Step 4: Run the test**
2135
+
2136
+ Run: `ruby -Itest test/unit/messenger/client_test.rb`
2137
+ Expected: PASS, 5 tests.
2138
+
2139
+ - [ ] **Step 5: Commit**
2140
+
2141
+ ```bash
2142
+ git add lib/flow_chat/messenger/client.rb test/unit/messenger/client_test.rb
2143
+ git commit -m "feat(messenger): send through the Send API"
2144
+ ```
2145
+
2146
+ ---
2147
+
2148
+ ## Task 12: The shared messaging gateway
2149
+
2150
+ **Goal:** The `entry[].messaging[]` envelope, dispatch, echo classification, delivery receipts, and context population, implemented once for both platforms.
2151
+
2152
+ **Files:**
2153
+ - Create: `lib/flow_chat/meta/messaging_gateway.rb`
2154
+ - Create: `test/unit/meta/messaging_gateway_test.rb`
2155
+ - Modify: `lib/flow_chat/session/middleware.rb:93-102`
2156
+
2157
+ **Acceptance Criteria:**
2158
+ - [ ] A text message sets `request.id`, `request.user_id`, `request.message_id`, `request.platform`, `request.gateway`, `context.input`
2159
+ - [ ] `request.msisdn` is `nil`
2160
+ - [ ] A `quick_reply` payload becomes `context.input`
2161
+ - [ ] A `postback` payload becomes `context.input` and drives the flow
2162
+ - [ ] An echo never drives the flow and is published with `echo_origin`
2163
+ - [ ] `delivery` and `read` publish `MESSAGE_STATUS` and are handled before the flow slot is claimed
2164
+ - [ ] Only one event per delivery drives a flow, and a second logs a warning
2165
+ - [ ] An event for an account other than the configured one is rejected with `:forbidden`
2166
+ - [ ] `platform_default_identifier` returns `:user_id` for `:messenger` and `:instagram`
2167
+
2168
+ **Verify:** `ruby -Itest test/unit/meta/messaging_gateway_test.rb` → PASS
2169
+
2170
+ **Steps:**
2171
+
2172
+ - [ ] **Step 1: Write the failing test**
2173
+
2174
+ Create `test/unit/meta/messaging_gateway_test.rb`. The test defines its own concrete subclass so the base class is exercised without depending on Task 13:
2175
+
2176
+ ```ruby
2177
+ require "test_helper"
2178
+
2179
+ class MessagingGatewayTest < Minitest::Test
2180
+ # The base class is abstract. This exercises it directly rather than through
2181
+ # a platform, so a failure here is unambiguously the envelope's fault.
2182
+ class TestGateway < FlowChat::Meta::MessagingGateway
2183
+ def platform = :messenger
2184
+ def gateway_name = :messenger_send_api
2185
+ def configuration_class = FlowChat::Messenger::Configuration
2186
+ def client_class = FlowChat::Messenger::Client
2187
+ def renderer_class = FlowChat::Messenger::Renderer
2188
+ def self.choice_mapper_class = FlowChat::Messenger::Middleware::ChoiceMapper
2189
+ end
2190
+
2191
+ def setup
2192
+ @config = FlowChat::Messenger::Configuration.new(nil)
2193
+ @config.page_id = "page_1"
2194
+ @config.access_token = "tok"
2195
+ @config.verify_token = "verify"
2196
+ @config.app_id = "our_app"
2197
+ @config.skip_signature_validation = true
2198
+
2199
+ @app = proc { |context| [:text, "Response", nil, nil] }
2200
+ @gateway = TestGateway.new(@app, @config)
2201
+ @sent = []
2202
+ @gateway.client.define_singleton_method(:send_message) do |*args, **kwargs|
2203
+ {"message_id" => "mid.sent"}
2204
+ end
2205
+ end
2206
+
2207
+ def test_text_message_populates_context
2208
+ context = post(messaging_payload({"message" => {"mid" => "mid.1", "text" => "Hello"}}))
2209
+
2210
+ assert_equal "Hello", context.input
2211
+ assert_equal "psid_1", context["request.user_id"]
2212
+ assert_equal "psid_1", context["request.id"]
2213
+ assert_equal "mid.1", context["request.message_id"]
2214
+ assert_equal :messenger, context["request.platform"]
2215
+ assert_equal :messenger_send_api, context["request.gateway"]
2216
+ assert_nil context["request.msisdn"]
2217
+ end
2218
+
2219
+ def test_quick_reply_payload_is_the_input
2220
+ context = post(messaging_payload({
2221
+ "message" => {"mid" => "mid.2", "text" => "Alpha", "quick_reply" => {"payload" => "choice_a"}}
2222
+ }))
2223
+
2224
+ assert_equal "choice_a", context.input
2225
+ end
2226
+
2227
+ def test_postback_payload_is_the_input
2228
+ context = post(messaging_payload({"postback" => {"mid" => "mid.3", "payload" => "get_started"}}))
2229
+
2230
+ assert_equal "get_started", context.input
2231
+ end
2232
+
2233
+ def test_echo_never_drives_a_flow_and_reports_its_origin
2234
+ events = capture_webhook_received do
2235
+ post(messaging_payload({
2236
+ "message" => {"mid" => "mid.4", "text" => "From a human", "is_echo" => true}
2237
+ }))
2238
+ end
2239
+
2240
+ assert_equal 1, events.size
2241
+ assert_equal :human_agent, events.first[:echo_origin]
2242
+ end
2243
+
2244
+ def test_echo_from_our_own_app_is_labelled_self
2245
+ events = capture_webhook_received do
2246
+ post(messaging_payload({
2247
+ "message" => {"mid" => "mid.5", "text" => "Ours", "is_echo" => true, "app_id" => "our_app"}
2248
+ }))
2249
+ end
2250
+
2251
+ assert_equal :self, events.first[:echo_origin]
2252
+ end
2253
+
2254
+ def test_delivery_receipt_publishes_status_and_leaves_the_flow_slot
2255
+ statuses = capture_events(FlowChat::Instrumentation::Events::MESSAGE_STATUS) do
2256
+ context = post({
2257
+ "object" => "page",
2258
+ "entry" => [{
2259
+ "id" => "page_1",
2260
+ "messaging" => [
2261
+ {"sender" => {"id" => "psid_1"}, "recipient" => {"id" => "page_1"}, "delivery" => {"mids" => ["mid.1"], "watermark" => 1}},
2262
+ {"sender" => {"id" => "psid_1"}, "recipient" => {"id" => "page_1"}, "message" => {"mid" => "mid.6", "text" => "Still here"}}
2263
+ ]
2264
+ }]
2265
+ })
2266
+
2267
+ assert_equal "Still here", context.input, "a receipt must not spend the flow slot"
2268
+ end
2269
+
2270
+ assert_equal 1, statuses.size
2271
+ end
2272
+
2273
+ def test_second_message_in_one_delivery_does_not_run
2274
+ context = post({
2275
+ "object" => "page",
2276
+ "entry" => [{
2277
+ "id" => "page_1",
2278
+ "messaging" => [
2279
+ {"sender" => {"id" => "psid_1"}, "recipient" => {"id" => "page_1"}, "message" => {"mid" => "mid.7", "text" => "First"}},
2280
+ {"sender" => {"id" => "psid_2"}, "recipient" => {"id" => "page_1"}, "message" => {"mid" => "mid.8", "text" => "Second"}}
2281
+ ]
2282
+ }]
2283
+ })
2284
+
2285
+ assert_equal "First", context.input
2286
+ end
2287
+
2288
+ def test_event_for_another_account_is_rejected
2289
+ context = post({
2290
+ "object" => "page",
2291
+ "entry" => [{
2292
+ "id" => "someone_elses_page",
2293
+ "messaging" => [{"sender" => {"id" => "psid_1"}, "recipient" => {"id" => "someone_elses_page"}, "message" => {"mid" => "m", "text" => "Hi"}}]
2294
+ }]
2295
+ })
2296
+
2297
+ assert_equal :forbidden, context.controller.last_head_status
2298
+ end
2299
+
2300
+ def test_session_identifier_defaults_to_user_id
2301
+ middleware = FlowChat::Session::Middleware.allocate
2302
+ context = FlowChat::Context.new
2303
+ context["request.platform"] = :messenger
2304
+
2305
+ assert_equal :user_id, middleware.send(:platform_default_identifier, context)
2306
+ end
2307
+
2308
+ private
2309
+
2310
+ def messaging_payload(event)
2311
+ {
2312
+ "object" => "page",
2313
+ "entry" => [{
2314
+ "id" => "page_1",
2315
+ "messaging" => [
2316
+ {"sender" => {"id" => "psid_1"}, "recipient" => {"id" => "page_1"}, "timestamp" => 1_700_000_000}.merge(event)
2317
+ ]
2318
+ }]
2319
+ }
2320
+ end
2321
+
2322
+ def post(body)
2323
+ context = build_messaging_context(body)
2324
+ @gateway.call(context)
2325
+ context
2326
+ end
2327
+ end
2328
+ ```
2329
+
2330
+ Add `build_messaging_context(body)`, `capture_events(event_name)` and `capture_webhook_received` to `test/support/test_helpers.rb` so phases 2 and 3 share them. `build_messaging_context` mirrors `create_context_with_request` from `test/unit/whatsapp/gateway/cloud_api_test.rb:851`: an `OpenStruct` request with `post?`, `get?`, `body` as a rewindable `StringIO`, `headers`, `cookies`, and a controller recording `render`, `head` and `last_head_status`.
2331
+
2332
+ - [ ] **Step 2: Run and watch it fail**
2333
+
2334
+ Run: `ruby -Itest test/unit/meta/messaging_gateway_test.rb`
2335
+ Expected: FAIL with `NameError: uninitialized constant FlowChat::Meta::MessagingGateway`.
2336
+
2337
+ - [ ] **Step 3: Implement the base gateway**
2338
+
2339
+ Create `lib/flow_chat/meta/messaging_gateway.rb`:
2340
+
2341
+ ```ruby
2342
+ require "json"
2343
+
2344
+ module FlowChat
2345
+ module Meta
2346
+ # The Messenger Platform envelope, shared by Facebook Messenger and
2347
+ # Instagram DMs. Both deliver entry[].messaging[] and both send through the
2348
+ # same Send API, so the envelope is implemented once and each platform
2349
+ # supplies only what actually differs.
2350
+ class MessagingGateway
2351
+ include FlowChat::Instrumentation
2352
+ include FlowChat::GatewayAsyncSupport
2353
+ include FlowChat::Meta::SignatureValidation
2354
+ include FlowChat::Meta::WebhookVerification
2355
+
2356
+ attr_reader :context, :client
2357
+
2358
+ def initialize(app, config = nil)
2359
+ @app = app
2360
+ @config = config || configuration_class.from_credentials
2361
+ @client = client_class.new(@config)
2362
+
2363
+ FlowChat.logger.info { "#{log_tag}: Initialized #{platform} gateway for account #{@config.account_id}" }
2364
+ end
2365
+
2366
+ def call(context)
2367
+ @context = context
2368
+ @controller = context.controller
2369
+ request = @controller.request
2370
+
2371
+ unless in_background?
2372
+ if request.get? && request.params["hub.mode"] == "subscribe"
2373
+ return handle_verification(context)
2374
+ end
2375
+ end
2376
+
2377
+ return handle_webhook(context) if request.post?
2378
+
2379
+ FlowChat.logger.warn { "#{log_tag}: Invalid request method or parameters - returning bad request" }
2380
+ @controller.head :bad_request
2381
+ end
2382
+
2383
+ def self.configure_middleware_stack(builder, custom_middleware)
2384
+ builder.use custom_middleware
2385
+ builder.use choice_mapper_class
2386
+ end
2387
+
2388
+ # --- Hooks each platform overrides ---
2389
+
2390
+ def platform
2391
+ raise NotImplementedError
2392
+ end
2393
+
2394
+ def gateway_name
2395
+ raise NotImplementedError
2396
+ end
2397
+
2398
+ def configuration_class
2399
+ raise NotImplementedError
2400
+ end
2401
+
2402
+ def client_class
2403
+ raise NotImplementedError
2404
+ end
2405
+
2406
+ def renderer_class
2407
+ raise NotImplementedError
2408
+ end
2409
+
2410
+ # Meta names the subscription this delivery came from. Messenger uses
2411
+ # "page". Instagram's value depends on how the app is set up, so each
2412
+ # platform states its own rather than sharing a guess.
2413
+ def expected_webhook_object
2414
+ "page"
2415
+ end
2416
+
2417
+ private
2418
+
2419
+ def handle_webhook(context)
2420
+ begin
2421
+ parse_request_body(@controller.request)
2422
+ rescue JSON::ParserError => e
2423
+ FlowChat.logger.error { "#{log_tag}: Failed to parse webhook body: #{e.message}" }
2424
+ return @controller.head :bad_request
2425
+ end
2426
+
2427
+ is_simulator_mode = simulate?(context)
2428
+ context["simulator_mode"] = true if is_simulator_mode
2429
+
2430
+ unless in_background? || is_simulator_mode || valid_webhook_signature?(@controller.request)
2431
+ FlowChat.logger.warn { "#{log_tag}: Invalid webhook signature - dropping request" }
2432
+ return @controller.head :ok
2433
+ end
2434
+
2435
+ if @body["object"].present? && @body["object"] != expected_webhook_object
2436
+ FlowChat.logger.debug { "#{log_tag}: Ignoring webhook for object '#{@body["object"]}'" }
2437
+ return @controller.head :ok
2438
+ end
2439
+
2440
+ entries = @body["entry"]
2441
+ unless entries.is_a?(Array) && entries.any?
2442
+ return @controller.head :ok
2443
+ end
2444
+
2445
+ # Only one event per delivery can drive a flow, because only one can own
2446
+ # the response to this request.
2447
+ flow_ran = false
2448
+
2449
+ entries.each do |entry|
2450
+ events = entry["messaging"] || entry["standby"]
2451
+ next unless events.is_a?(Array)
2452
+
2453
+ # Receipts are handled before any flow claims the slot. A receipt
2454
+ # arriving ahead of a message in the same batch would otherwise spend
2455
+ # the slot and the message would be lost.
2456
+ events.each { |event| handle_status(entry, event) if status_event?(event) }
2457
+
2458
+ events.each do |event|
2459
+ next if status_event?(event)
2460
+
2461
+ if echo?(event)
2462
+ publish_echo(entry, event)
2463
+ next
2464
+ end
2465
+
2466
+ unless drives_flow?(event)
2467
+ publish_unmodelled(entry, event)
2468
+ next
2469
+ end
2470
+
2471
+ if flow_ran
2472
+ FlowChat.logger.warn { "#{log_tag}: A second message arrived in the same delivery and was not processed" }
2473
+ next
2474
+ end
2475
+ flow_ran = true
2476
+
2477
+ case handle_message(context, entry, event)
2478
+ when :rejected then return @controller.head :forbidden
2479
+ when :enqueued then return @controller.head :ok
2480
+ when :rendered then return nil
2481
+ end
2482
+ end
2483
+ end
2484
+
2485
+ @controller.head :ok
2486
+ end
2487
+
2488
+ def status_event?(event)
2489
+ event.key?("delivery") || event.key?("read")
2490
+ end
2491
+
2492
+ def echo?(event)
2493
+ event.dig("message", "is_echo") == true
2494
+ end
2495
+
2496
+ def drives_flow?(event)
2497
+ event.key?("message") || event.key?("postback")
2498
+ end
2499
+
2500
+ def handle_message(context, entry, event)
2501
+ account_id = entry["id"]
2502
+ if account_id.to_s != @config.account_id.to_s
2503
+ FlowChat.logger.warn { "#{log_tag}: Webhook for account '#{account_id}' but configured for '#{@config.account_id}' - rejecting" }
2504
+ return :rejected
2505
+ end
2506
+
2507
+ sender_id = event.dig("sender", "id")
2508
+ message = event["message"] || event["postback"]
2509
+
2510
+ context["request.id"] = sender_id
2511
+ context["request.user_id"] = sender_id
2512
+ context["request.msisdn"] = nil
2513
+ context["request.message_id"] = message["mid"]
2514
+ context["request.gateway"] = gateway_name
2515
+ context["request.platform"] = platform
2516
+ context["request.timestamp"] = Time.current.iso8601
2517
+ context["request.body"] = @body
2518
+
2519
+ context["#{platform}.account.id"] = account_id
2520
+ context["#{platform}.client"] = @client
2521
+
2522
+ extract_message_content!(event, context)
2523
+
2524
+ instrument(Events::MESSAGE_RECEIVED, {
2525
+ from: sender_id,
2526
+ message: context.input,
2527
+ message_type: event.key?("postback") ? "postback" : "message",
2528
+ message_id: message["mid"]
2529
+ })
2530
+
2531
+ return (enqueue_async_job || :enqueued) && :enqueued if should_enqueue_async?
2532
+
2533
+ if context["simulator_mode"]
2534
+ handle_message_simulator(context)
2535
+ :rendered
2536
+ else
2537
+ handle_message_inline(context)
2538
+ :processed
2539
+ end
2540
+ end
2541
+
2542
+ # A postback's payload, a quick reply's payload, and otherwise the text.
2543
+ # An attachment-only turn has blank input, matching the media contract the
2544
+ # other gateways follow.
2545
+ def extract_message_content!(event, context)
2546
+ if event.key?("postback")
2547
+ context.input = event.dig("postback", "payload").to_s
2548
+ return
2549
+ end
2550
+
2551
+ message = event["message"]
2552
+
2553
+ if message["quick_reply"]
2554
+ context.input = message.dig("quick_reply", "payload").to_s
2555
+ return
2556
+ end
2557
+
2558
+ attachments = message["attachments"]
2559
+ if attachments.is_a?(Array) && attachments.any?
2560
+ attachment = attachments.first
2561
+ context["request.media"] = {
2562
+ type: normalize_attachment_type(attachment["type"]),
2563
+ url: attachment.dig("payload", "url")
2564
+ }
2565
+ end
2566
+
2567
+ context.input = message["text"].presence || ""
2568
+ end
2569
+
2570
+ # "file" is Meta's name for what every other gateway here calls a document.
2571
+ def normalize_attachment_type(type)
2572
+ case type.to_s
2573
+ when "file" then :document
2574
+ when "" then nil
2575
+ else type.to_s.to_sym
2576
+ end
2577
+ end
2578
+
2579
+ def handle_status(entry, event)
2580
+ %w[delivery read].each do |kind|
2581
+ payload = event[kind]
2582
+ next unless payload
2583
+
2584
+ instrument(Events::MESSAGE_STATUS, {
2585
+ platform: platform,
2586
+ gateway: gateway_name,
2587
+ account_id: entry["id"],
2588
+ recipient: event.dig("sender", "id"),
2589
+ status: kind,
2590
+ timestamp: event["timestamp"],
2591
+ value: payload
2592
+ })
2593
+ end
2594
+ end
2595
+
2596
+ # An echo reports a message sent on this thread by someone other than the
2597
+ # user. Which someone decides what the application does about it: a human
2598
+ # replying from the page inbox usually means the flow should stand down.
2599
+ def publish_echo(entry, event)
2600
+ instrument(Events::WEBHOOK_RECEIVED, {
2601
+ platform: platform,
2602
+ gateway: gateway_name,
2603
+ field: "message_echoes",
2604
+ account_id: entry["id"],
2605
+ echo_origin: echo_origin(event),
2606
+ value: event
2607
+ })
2608
+ end
2609
+
2610
+ def echo_origin(event)
2611
+ app_id = event.dig("message", "app_id")
2612
+
2613
+ return :human_agent if app_id.blank?
2614
+ return :self if app_id.to_s == @config.app_id.to_s
2615
+
2616
+ :other_app
2617
+ end
2618
+
2619
+ # Everything that is not a message, its receipt, or an echo. Reactions,
2620
+ # referrals, opt-ins, handovers, policy enforcement: all of it is the
2621
+ # application's domain, so it is published whole rather than interpreted.
2622
+ def publish_unmodelled(entry, event)
2623
+ field = (event.keys - %w[sender recipient timestamp]).first
2624
+
2625
+ FlowChat.logger.info { "#{log_tag}: Publishing webhook event '#{field}'" }
2626
+
2627
+ instrument(Events::WEBHOOK_RECEIVED, {
2628
+ platform: platform,
2629
+ gateway: gateway_name,
2630
+ field: field,
2631
+ account_id: entry["id"],
2632
+ value: event
2633
+ })
2634
+ end
2635
+
2636
+ def handle_message_inline(context)
2637
+ response = @app.call(context)
2638
+ return unless response
2639
+
2640
+ type, prompt, choices, media = response
2641
+
2642
+ result = report_delivery_failure(
2643
+ context,
2644
+ to: context["request.user_id"],
2645
+ session_id: context["request.id"],
2646
+ message: prompt,
2647
+ message_type: (type == :prompt) ? "prompt" : "terminal",
2648
+ gateway: gateway_name,
2649
+ platform: platform
2650
+ ) do
2651
+ @client.send_message(context["request.user_id"], prompt, choices: choices, media: media)
2652
+ end
2653
+
2654
+ context["#{platform}.message_result"] = result
2655
+
2656
+ instrument(Events::MESSAGE_SENT, {
2657
+ to: context["request.user_id"],
2658
+ session_id: context["request.id"],
2659
+ message: prompt,
2660
+ message_type: (type == :prompt) ? "prompt" : "terminal",
2661
+ gateway: gateway_name,
2662
+ platform: platform,
2663
+ content_length: prompt.to_s.length,
2664
+ platform_message_id: platform_message_id_from(result),
2665
+ timestamp: context["request.timestamp"]
2666
+ })
2667
+ end
2668
+
2669
+ # The Send API answers with the id it assigned, flatter than WhatsApp's
2670
+ # messages[0].id.
2671
+ def platform_message_id_from(result)
2672
+ return nil unless result.is_a?(Hash)
2673
+
2674
+ result["message_id"]
2675
+ end
2676
+
2677
+ def handle_message_simulator(context)
2678
+ response = @app.call(context)
2679
+ return unless response
2680
+
2681
+ _, prompt, choices, media = response
2682
+ rendered = renderer_class.new(prompt, choices: choices, media: media).render
2683
+
2684
+ @controller.render json: {
2685
+ mode: "simulator",
2686
+ webhook_processed: true,
2687
+ would_send: rendered,
2688
+ message_info: {
2689
+ to: context["request.user_id"],
2690
+ timestamp: Time.now.iso8601
2691
+ }
2692
+ }
2693
+
2694
+ nil
2695
+ end
2696
+
2697
+ def simulate?(context)
2698
+ return false unless context["enable_simulator"]
2699
+
2700
+ @body.dig("simulator_mode") &&
2701
+ FlowChat::Security.valid_simulator_cookie?(@controller.request.cookies[FlowChat::Security::SIMULATOR_COOKIE_NAME])
2702
+ end
2703
+
2704
+ def parse_request_body(request)
2705
+ return @body if @body
2706
+
2707
+ @body = if request.body.nil?
2708
+ {}
2709
+ else
2710
+ request.body.rewind if request.body.respond_to?(:rewind)
2711
+ JSON.parse(request.body.read)
2712
+ end
2713
+ end
2714
+
2715
+ end
2716
+ end
2717
+ end
2718
+ ```
2719
+
2720
+ `platform`, `platform_label`, `configuration_error_class` and `log_tag` are not defined here: `GatewayIdentity` (Task 2) declares them, and the three raising ones are each subclass's job. Do not add local defaults, or a platform that forgets one will silently borrow another's identity.
2721
+
2722
+ Also delete the `def platform; raise NotImplementedError; end` shown in the hooks section above for the same reason. It duplicates `GatewayIdentity`. Keep the other three hooks (`configuration_class`, `client_class`, `renderer_class`) raising here, since those are this class's contract rather than the identity seam's.
2723
+
2724
+ The `should_enqueue_async?` line above is awkward. Write it plainly instead:
2725
+
2726
+ ```ruby
2727
+ if should_enqueue_async?
2728
+ enqueue_async_job
2729
+ return :enqueued
2730
+ end
2731
+ ```
2732
+
2733
+ - [ ] **Step 4: Teach the session middleware the two platforms**
2734
+
2735
+ In `lib/flow_chat/session/middleware.rb`, change `platform_default_identifier`:
2736
+
2737
+ ```ruby
2738
+ def platform_default_identifier(context)
2739
+ platform = context["request.platform"]
2740
+
2741
+ case platform
2742
+ when :whatsapp
2743
+ :msisdn
2744
+ when :messenger, :instagram
2745
+ # Neither platform exposes a phone number. The sender id is scoped to
2746
+ # the app and the account, and is stable per user.
2747
+ :user_id
2748
+ else
2749
+ :request_id
2750
+ end
2751
+ end
2752
+ ```
2753
+
2754
+ - [ ] **Step 5: Run the tests**
2755
+
2756
+ Run: `ruby -Itest test/unit/meta/messaging_gateway_test.rb`
2757
+ Expected: PASS, 10 tests. `TestGateway` makes this task self-contained, so it must go green here without Task 13.
2758
+
2759
+ Run: `bundle exec rake test`
2760
+ Expected: PASS, 0 failures. The session-middleware change affects every platform, so watch for identifier regressions in the WhatsApp and Telegram suites.
2761
+
2762
+ - [ ] **Step 6: Commit**
2763
+
2764
+ ```bash
2765
+ git add lib/flow_chat/meta/messaging_gateway.rb lib/flow_chat/session/middleware.rb \
2766
+ test/unit/meta/messaging_gateway_test.rb test/support/test_helpers.rb
2767
+ git commit -m "feat(meta): implement the Messenger Platform envelope once"
2768
+ ```
2769
+
2770
+ ---
2771
+
2772
+ ## Task 13: Messenger gateway and choice mapper
2773
+
2774
+ **Goal:** The Messenger subclass, and the middleware that maps a reply back to the choice key the flow used.
2775
+
2776
+ **Files:**
2777
+ - Create: `lib/flow_chat/messenger/gateway/send_api.rb`
2778
+ - Create: `lib/flow_chat/messenger/middleware/choice_mapper.rb`
2779
+ - Create: `test/unit/messenger/middleware/choice_mapper_test.rb`
2780
+
2781
+ **Acceptance Criteria:**
2782
+ - [ ] `FlowChat::Messenger::Gateway::SendApi` subclasses `FlowChat::Meta::MessagingGateway` and overrides `platform`, `gateway_name`, `configuration_class`, `client_class`, `renderer_class`, `choice_mapper_class`
2783
+ - [ ] A tapped quick reply resolves to the flow's original choice key
2784
+ - [ ] On the numbered rung, typing `"3"` resolves to the third choice's key
2785
+ - [ ] Ids resolve before positions, so a choice labelled `"1"` is not shadowed by position `"1"`
2786
+ - [ ] The position map is absent on the quick-reply and carousel rungs
2787
+ - [ ] **Both maps are cleared together.** A test proves that a typed digit on a screen with no choices, reached after a numbered menu, stays a digit rather than resolving to that menu's third choice key. This was a real bug in the WhatsApp mapper, found and fixed in Task 6, and the mechanism here is identical: `create_mappings` runs only when a screen has choices, so a screen without them clears nothing unless clearing is explicit.
2788
+
2789
+ **Verify:** `ruby -Itest test/unit/messenger/middleware/choice_mapper_test.rb && ruby -Itest test/unit/meta/messaging_gateway_test.rb` → PASS
2790
+
2791
+ **Steps:**
2792
+
2793
+ - [ ] **Step 1: Write the failing choice-mapper test**
2794
+
2795
+ Create `test/unit/messenger/middleware/choice_mapper_test.rb`:
2796
+
2797
+ ```ruby
2798
+ require "test_helper"
2799
+
2800
+ class MessengerChoiceMapperTest < Minitest::Test
2801
+ def build(app)
2802
+ FlowChat::Messenger::Middleware::ChoiceMapper.new(app)
2803
+ end
2804
+
2805
+ def context_with(input, session: nil)
2806
+ context = FlowChat::Context.new
2807
+ context.input = input
2808
+ context["session.id"] = "session_1"
2809
+ context.session = session || FlowChat::TestSupport::MockSessionStore.new
2810
+ context
2811
+ end
2812
+
2813
+ def test_tapped_quick_reply_resolves_to_the_original_key
2814
+ choices = {"create" => "Create Account", "login" => "Log In"}
2815
+ mapper = build(->(_ctx) { [:prompt, "Pick", choices, nil] })
2816
+
2817
+ first = context_with("")
2818
+ _, _, transformed, _ = mapper.call(first)
2819
+
2820
+ tapped = transformed.keys.first
2821
+ second = context_with(tapped, session: first.session)
2822
+ mapper.call(second)
2823
+
2824
+ assert_equal "create", second.input
2825
+ end
2826
+
2827
+ def test_typed_number_resolves_on_the_numbered_rung
2828
+ choices = (1..31).to_h { |i| ["k#{i}", "Option #{i}"] }
2829
+ mapper = build(->(_ctx) { [:prompt, "Pick", choices, nil] })
2830
+
2831
+ first = context_with("")
2832
+ mapper.call(first)
2833
+
2834
+ second = context_with("3", session: first.session)
2835
+ mapper.call(second)
2836
+
2837
+ assert_equal "k3", second.input
2838
+ end
2839
+
2840
+ # A generated id and a position occupy the same key space: normalize_label
2841
+ # keeps digits, so a choice labelled "1" generates the id "1".
2842
+ def test_generated_ids_win_over_positions
2843
+ choices = {"a" => "2", "b" => "1"}
2844
+ mapper = build(->(_ctx) { [:prompt, "Pick", choices, nil] })
2845
+
2846
+ first = context_with("")
2847
+ mapper.call(first)
2848
+
2849
+ second = context_with("1", session: first.session)
2850
+ mapper.call(second)
2851
+
2852
+ assert_equal "b", second.input, "the id for the label \"1\" must win over position 1"
2853
+ end
2854
+
2855
+ def test_no_position_map_on_the_quick_reply_rung
2856
+ choices = {"a" => "Alpha", "b" => "Beta"}
2857
+ mapper = build(->(_ctx) { [:prompt, "Pick", choices, nil] })
2858
+
2859
+ context = context_with("")
2860
+ mapper.call(context)
2861
+
2862
+ assert_nil context.session.get("messenger.position_mapping")
2863
+ end
2864
+ end
2865
+ ```
2866
+
2867
+ Use whatever session double the existing `test/unit/whatsapp/middleware/choice_mapper_test.rb` uses rather than assuming `MockSessionStore`.
2868
+
2869
+ - [ ] **Step 2: Run and watch it fail**
2870
+
2871
+ Run: `ruby -Itest test/unit/messenger/middleware/choice_mapper_test.rb`
2872
+ Expected: FAIL with `NameError: uninitialized constant FlowChat::Messenger::Middleware`.
2873
+
2874
+ - [ ] **Step 3: Write the gateway subclass**
2875
+
2876
+ Create `lib/flow_chat/messenger/gateway/send_api.rb`:
2877
+
2878
+ ```ruby
2879
+ module FlowChat
2880
+ module Messenger
2881
+ module Gateway
2882
+ # Facebook Messenger, on the shared Messenger Platform envelope.
2883
+ class SendApi < FlowChat::Meta::MessagingGateway
2884
+ def platform
2885
+ :messenger
2886
+ end
2887
+
2888
+ def gateway_name
2889
+ :messenger_send_api
2890
+ end
2891
+
2892
+ def configuration_class
2893
+ FlowChat::Messenger::Configuration
2894
+ end
2895
+
2896
+ def client_class
2897
+ FlowChat::Messenger::Client
2898
+ end
2899
+
2900
+ def renderer_class
2901
+ FlowChat::Messenger::Renderer
2902
+ end
2903
+
2904
+ def self.choice_mapper_class
2905
+ FlowChat::Messenger::Middleware::ChoiceMapper
2906
+ end
2907
+
2908
+ private
2909
+
2910
+ def configuration_error_class
2911
+ FlowChat::Messenger::ConfigurationError
2912
+ end
2913
+
2914
+ def platform_label
2915
+ "Messenger"
2916
+ end
2917
+ end
2918
+ end
2919
+ end
2920
+ end
2921
+ ```
2922
+
2923
+ - [ ] **Step 4: Write the choice mapper**
2924
+
2925
+ Create `lib/flow_chat/messenger/middleware/choice_mapper.rb`:
2926
+
2927
+ ```ruby
2928
+ module FlowChat
2929
+ module Messenger
2930
+ module Middleware
2931
+ # Maps a reply back to the choice key the flow used.
2932
+ #
2933
+ # Two key spaces can be live at once. A tap sends the payload id the
2934
+ # renderer put on the button, and on the numbered rung a typed digit sends
2935
+ # a position. They are stored separately and resolved ids first, because
2936
+ # the spaces overlap: IdGenerator keeps digits, so a choice labelled "1"
2937
+ # generates the id "1", which is not necessarily the first choice.
2938
+ class ChoiceMapper
2939
+ ID_KEY = "messenger.choice_mapping"
2940
+ POSITION_KEY = "messenger.position_mapping"
2941
+
2942
+ def initialize(app)
2943
+ @app = app
2944
+ end
2945
+
2946
+ def call(context)
2947
+ @context = context
2948
+ @session = context.session
2949
+
2950
+ handle_choice_input if intercept?
2951
+
2952
+ type, prompt, choices, media = @app.call(context)
2953
+
2954
+ choices = create_mappings(choices) if choices.present?
2955
+
2956
+ [type, prompt, choices, media]
2957
+ end
2958
+
2959
+ private
2960
+
2961
+ def platform_limits
2962
+ FlowChat::Config.messenger
2963
+ end
2964
+
2965
+ def always_number?
2966
+ false
2967
+ end
2968
+
2969
+ def id_key
2970
+ self.class::ID_KEY
2971
+ end
2972
+
2973
+ def position_key
2974
+ self.class::POSITION_KEY
2975
+ end
2976
+
2977
+ def resolved_choice
2978
+ input = @context.input.to_s
2979
+ return nil if input.empty?
2980
+
2981
+ (@session.get(id_key) || {})[input] || (@session.get(position_key) || {})[input]
2982
+ end
2983
+
2984
+ def intercept?
2985
+ @context.input.present? && resolved_choice.present?
2986
+ end
2987
+
2988
+ def handle_choice_input
2989
+ original = resolved_choice
2990
+ FlowChat.logger.info { "#{self.class.name}: Resolving input #{@context.input} to #{original}" }
2991
+ @context.input = original
2992
+ end
2993
+
2994
+ # Both maps are cleared together. Clearing only one leaves the other to
2995
+ # hijack a later turn: a position map surviving from a numbered menu
2996
+ # rewrites a typed "3" on the next free-text screen into that menu's third
2997
+ # choice key. This was a real bug in the WhatsApp mapper, found in Task 6
2998
+ # and fixed there. `create_mappings` only runs when a screen HAS choices,
2999
+ # so a screen without them clears nothing unless clearing is explicit.
3000
+ #
3001
+ # It bites hardest on Instagram, which stores positions at every rung.
3002
+ def clear_mappings
3003
+ @session.delete(id_key)
3004
+ @session.delete(position_key)
3005
+ end
3006
+
3007
+ def create_mappings(choices)
3008
+ generator = FlowChat::IdGenerator.new(max_length: 1000)
3009
+ id_choices = {}
3010
+ id_mapping = {}
3011
+
3012
+ choices.each do |key, label|
3013
+ generated_id = generator.generate_id(label.to_s)
3014
+ id_choices[generated_id] = label
3015
+ id_mapping[generated_id] = key.to_s
3016
+ end
3017
+
3018
+ @session.set(id_key, id_mapping)
3019
+
3020
+ if FlowChat::Meta::ChoiceLadder.numbers_in_body?(choices.length, platform_limits, always_number: always_number?)
3021
+ @session.set(position_key, choices.keys.map.with_index(1) { |key, i| [i.to_s, key.to_s] }.to_h)
3022
+ else
3023
+ @session.delete(position_key)
3024
+ end
3025
+
3026
+ id_choices
3027
+ end
3028
+ end
3029
+ end
3030
+ end
3031
+ end
3032
+ ```
3033
+
3034
+ - [ ] **Step 5: Run the tests**
3035
+
3036
+ Run: `ruby -Itest test/unit/messenger/middleware/choice_mapper_test.rb`
3037
+ Expected: PASS, 4 tests.
3038
+
3039
+ Run: `ruby -Itest test/unit/meta/messaging_gateway_test.rb`
3040
+ Expected: PASS, 10 tests, still green. Leave `TestGateway` in place: it keeps the base class covered independently of either real platform.
3041
+
3042
+ Add one test to `test/unit/messenger/middleware/choice_mapper_test.rb` proving the subclass wires the hooks, since `TestGateway` cannot prove that:
3043
+
3044
+ ```ruby
3045
+ def test_gateway_exposes_the_messenger_hooks
3046
+ config = FlowChat::Messenger::Configuration.new(nil)
3047
+ config.page_id = "page_1"
3048
+ config.access_token = "tok"
3049
+ config.verify_token = "verify"
3050
+
3051
+ gateway = FlowChat::Messenger::Gateway::SendApi.new(proc {}, config)
3052
+
3053
+ assert_equal :messenger, gateway.platform
3054
+ assert_equal :messenger_send_api, gateway.gateway_name
3055
+ assert_equal FlowChat::Messenger::Renderer, gateway.renderer_class
3056
+ assert_equal FlowChat::Messenger::Middleware::ChoiceMapper,
3057
+ FlowChat::Messenger::Gateway::SendApi.choice_mapper_class
3058
+ end
3059
+ ```
3060
+
3061
+ - [ ] **Step 6: Run the full suite**
3062
+
3063
+ Run: `bundle exec rake test`
3064
+ Expected: PASS, 0 failures.
3065
+
3066
+ - [ ] **Step 7: Commit**
3067
+
3068
+ ```bash
3069
+ git add lib/flow_chat/messenger/gateway/send_api.rb lib/flow_chat/messenger/middleware/choice_mapper.rb \
3070
+ test/unit/messenger/middleware/choice_mapper_test.rb test/unit/meta/messaging_gateway_test.rb
3071
+ git commit -m "feat(messenger): wire the gateway and resolve tapped and typed replies"
3072
+ ```
3073
+
3074
+ ---
3075
+
3076
+ ## Task 14: Messenger integration test
3077
+
3078
+ **Goal:** A full webhook-to-send cycle through a real flow, session store and middleware stack.
3079
+
3080
+ **Files:**
3081
+ - Create: `test/integration/messenger_integration_test.rb`
3082
+
3083
+ **Acceptance Criteria:**
3084
+ - [ ] A first webhook starts a flow and sends the first prompt
3085
+ - [ ] A tapped quick reply on the second webhook advances the flow, with session state carried
3086
+ - [ ] A terminal screen sends the final message
3087
+ - [ ] Async mode enqueues instead of sending, and returns 200
3088
+
3089
+ **Verify:** `ruby -Itest test/integration/messenger_integration_test.rb` → PASS
3090
+
3091
+ **Steps:**
3092
+
3093
+ - [ ] **Step 1: Read the model first**
3094
+
3095
+ Read `test/integration/whatsapp_integration_test.rb` in full. Copy its structure: processor construction, session store, flow definition, and how it asserts on sends. Do not invent a new harness.
3096
+
3097
+ - [ ] **Step 2: Write the test**
3098
+
3099
+ Create `test/integration/messenger_integration_test.rb` with a two-screen flow:
3100
+
3101
+ ```ruby
3102
+ require "test_helper"
3103
+
3104
+ class MessengerIntegrationTest < Minitest::Test
3105
+ class RegistrationFlow < FlowChat::Flow
3106
+ def main_page
3107
+ name = app.screen(:name) { |prompt| prompt.ask "What is your name?" }
3108
+ plan = app.screen(:plan) do |prompt|
3109
+ prompt.select "Choose a plan", {"basic" => "Basic", "pro" => "Pro"}
3110
+ end
3111
+ app.say "Thanks #{name}, you chose #{plan}."
3112
+ end
3113
+ end
3114
+
3115
+ def setup
3116
+ @config = FlowChat::Messenger::Configuration.new(nil)
3117
+ @config.page_id = "page_1"
3118
+ @config.access_token = "tok"
3119
+ @config.verify_token = "verify"
3120
+ @config.skip_signature_validation = true
3121
+
3122
+ @sent = []
3123
+ FlowChat::Config.cache = FlowChat::TestSupport::MockCache.new
3124
+ end
3125
+
3126
+ def test_full_conversation
3127
+ first = run_webhook(text: "Hello")
3128
+ assert_match(/What is your name/, last_sent_prompt)
3129
+
3130
+ run_webhook(text: "Ama")
3131
+ assert_match(/Choose a plan/, last_sent_prompt)
3132
+
3133
+ tapped = last_sent_choices.keys.first
3134
+ run_webhook(quick_reply: tapped)
3135
+ assert_match(/Thanks Ama/, last_sent_prompt)
3136
+ end
3137
+ end
3138
+ ```
3139
+
3140
+ The three helpers, in full. `send_message` is replaced with a recorder rather than stubbed over HTTP, because what this test asserts is the prompt and choices the flow produced, not the wire format (Task 11 covers that):
3141
+
3142
+ ```ruby
3143
+ private
3144
+
3145
+ def processor_for(controller)
3146
+ FlowChat::Processor.new(controller) do |config|
3147
+ config.use_gateway FlowChat::Messenger::Gateway::SendApi, @config
3148
+ config.use_session_store FlowChat::Session::CacheSessionStore
3149
+ end
3150
+ end
3151
+
3152
+ # Replaces the client's send with a recorder. The gateway builds its client in
3153
+ # the constructor, so this reaches in after the processor is built.
3154
+ def record_sends(gateway)
3155
+ sent = @sent
3156
+ gateway.client.define_singleton_method(:send_message) do |to, prompt, choices: nil, media: nil|
3157
+ sent << {to: to, prompt: prompt, choices: choices, media: media}
3158
+ {"recipient_id" => to, "message_id" => "mid.#{sent.length}"}
3159
+ end
3160
+ end
3161
+
3162
+ def run_webhook(text: nil, quick_reply: nil)
3163
+ message = {"mid" => "mid.in.#{@sent.length}"}
3164
+ if quick_reply
3165
+ message["text"] = "tapped"
3166
+ message["quick_reply"] = {"payload" => quick_reply}
3167
+ else
3168
+ message["text"] = text
3169
+ end
3170
+
3171
+ run_raw_webhook({
3172
+ "object" => "page",
3173
+ "entry" => [{
3174
+ "id" => "page_1",
3175
+ "messaging" => [{
3176
+ "sender" => {"id" => "psid_1"},
3177
+ "recipient" => {"id" => "page_1"},
3178
+ "timestamp" => 1_700_000_000,
3179
+ "message" => message
3180
+ }]
3181
+ }]
3182
+ })
3183
+ end
3184
+
3185
+ def run_raw_webhook(body)
3186
+ context = build_messaging_context(body)
3187
+ processor = processor_for(context.controller)
3188
+ record_sends(processor.gateway)
3189
+ processor.run(RegistrationFlow, :main_page)
3190
+ context
3191
+ end
3192
+
3193
+ def last_sent_prompt
3194
+ @sent.last[:prompt]
3195
+ end
3196
+
3197
+ def last_sent_choices
3198
+ @sent.last[:choices]
3199
+ end
3200
+ ```
3201
+
3202
+ `processor.gateway` may not be exposed. Check `lib/flow_chat/processor.rb` first: if the built gateway is not reachable from the processor, record sends by stubbing `FlowChat::Messenger::Client#send_message` on the class for the duration of the test instead, and note which approach you used.
3203
+
3204
+ - [ ] **Step 3: Run it**
3205
+
3206
+ Run: `ruby -Itest test/integration/messenger_integration_test.rb`
3207
+ Expected: PASS.
3208
+
3209
+ - [ ] **Step 4: Add the async case**
3210
+
3211
+ Add a test that builds the processor with `config.use_async(factory: :messenger)`, registers that factory, and asserts the webhook enqueues rather than sending. Follow `test/integration/async_flow_execution_test.rb` for the job double.
3212
+
3213
+ Run: `ruby -Itest test/integration/messenger_integration_test.rb`
3214
+ Expected: PASS.
3215
+
3216
+ - [ ] **Step 5: Run the full suite**
3217
+
3218
+ Run: `bundle exec rake test`
3219
+ Expected: PASS, 0 failures.
3220
+
3221
+ - [ ] **Step 6: Commit**
3222
+
3223
+ ```bash
3224
+ git add test/integration/messenger_integration_test.rb test/support/test_helpers.rb
3225
+ git commit -m "test(messenger): drive a conversation end to end"
3226
+ ```
3227
+
3228
+ ---
3229
+
3230
+ # Phase 3: Instagram
3231
+
3232
+ ## Task 15: Instagram configuration
3233
+
3234
+ **Goal:** Instagram credentials on the Facebook Login path, with Instagram's own limits.
3235
+
3236
+ **Files:**
3237
+ - Create: `lib/flow_chat/instagram/configuration.rb`
3238
+ - Create: `test/unit/instagram/configuration_test.rb`
3239
+ - Modify: `lib/flow_chat/config.rb` (add `Config.instagram`)
3240
+
3241
+ **Acceptance Criteria:**
3242
+ - [ ] `FlowChat::Instagram::Configuration` carries `page_id`, `instagram_account_id`, `access_token`, `verify_token`, `app_id`, `app_secret`, `skip_signature_validation`
3243
+ - [ ] `from_credentials` reads `instagram:` credentials with `INSTAGRAM_*` env fallback
3244
+ - [ ] `valid?` requires `access_token`, `page_id` and `verify_token`
3245
+ - [ ] `FlowChat::Config.instagram.max_text_length` is 1000
3246
+ - [ ] `account_id` returns `page_id`, since the Facebook Login path keys webhooks on the linked page
3247
+
3248
+ **Verify:** `ruby -Itest test/unit/instagram/configuration_test.rb` → PASS
3249
+
3250
+ **Steps:**
3251
+
3252
+ - [ ] **Step 1: Write the failing test**
3253
+
3254
+ Create `test/unit/instagram/configuration_test.rb` following the shape of `test/unit/messenger/configuration_test.rb`, asserting:
3255
+
3256
+ ```ruby
3257
+ def test_limits_are_instagram_specific
3258
+ assert_equal 1000, FlowChat::Config.instagram.max_text_length
3259
+ assert_equal 13, FlowChat::Config.instagram.max_quick_replies
3260
+ assert_equal 10, FlowChat::Config.instagram.max_carousel_elements
3261
+ end
3262
+
3263
+ def test_account_id_is_the_linked_page
3264
+ config = FlowChat::Instagram::Configuration.new(nil)
3265
+ config.page_id = "page_1"
3266
+ config.instagram_account_id = "ig_1"
3267
+
3268
+ assert_equal "page_1", config.account_id
3269
+ end
3270
+ ```
3271
+
3272
+ - [ ] **Step 2: Run and watch it fail**
3273
+
3274
+ Run: `ruby -Itest test/unit/instagram/configuration_test.rb`
3275
+ Expected: FAIL with `NameError: uninitialized constant FlowChat::Instagram`.
3276
+
3277
+ - [ ] **Step 3: Add the global config object**
3278
+
3279
+ In `lib/flow_chat/config.rb`:
3280
+
3281
+ ```ruby
3282
+ # Instagram-specific configuration object
3283
+ def self.instagram
3284
+ @instagram ||= InstagramConfig.new
3285
+ end
3286
+ ```
3287
+
3288
+ ```ruby
3289
+ class InstagramConfig
3290
+ attr_reader :api_base_url, :max_text_length, :max_quick_replies,
3291
+ :max_carousel_elements, :max_buttons_per_element,
3292
+ :max_quick_reply_title, :max_button_title, :max_element_title
3293
+
3294
+ def initialize
3295
+ @api_base_url = "https://graph.facebook.com/v23.0"
3296
+ # Meta: "Message text must be UTF-8 and be 1,000 bytes or less."
3297
+ @max_text_length = 1000
3298
+ @max_quick_replies = 13
3299
+ @max_quick_reply_title = 20
3300
+ @max_carousel_elements = 10
3301
+ @max_buttons_per_element = 3
3302
+ @max_button_title = 20
3303
+ @max_element_title = 80
3304
+ end
3305
+ end
3306
+ ```
3307
+
3308
+ - [ ] **Step 4: Write the configuration class**
3309
+
3310
+ Create `lib/flow_chat/instagram/configuration.rb`, mirroring `lib/flow_chat/messenger/configuration.rb` with these differences: an extra `instagram_account_id` accessor, `INSTAGRAM_*` env keys, `Rails.application.credentials.instagram`, `api_base_url` from `FlowChat::Config.instagram`, and an `Instagram::ConfigurationError`. `account_id` returns `page_id`.
3311
+
3312
+ ```ruby
3313
+ module FlowChat
3314
+ module Instagram
3315
+ class ConfigurationError < StandardError; end
3316
+
3317
+ class Configuration
3318
+ include FlowChat::NamedConfiguration
3319
+
3320
+ attr_accessor :access_token, :page_id, :instagram_account_id, :verify_token,
3321
+ :app_id, :app_secret, :name, :skip_signature_validation
3322
+
3323
+ def initialize(name)
3324
+ @name = name
3325
+ @skip_signature_validation = false
3326
+
3327
+ FlowChat.logger.debug { "Instagram::Configuration: Initialized configuration with name: #{name || "anonymous"}" }
3328
+
3329
+ register_as(name) if name.present?
3330
+ end
3331
+
3332
+ def self.from_credentials
3333
+ config = new(nil)
3334
+
3335
+ if defined?(Rails) && Rails.respond_to?(:application) && Rails.application&.credentials&.instagram
3336
+ credentials = Rails.application.credentials.instagram
3337
+ config.access_token = credentials[:access_token]
3338
+ config.page_id = credentials[:page_id]
3339
+ config.instagram_account_id = credentials[:instagram_account_id]
3340
+ config.verify_token = credentials[:verify_token]
3341
+ config.app_id = credentials[:app_id]
3342
+ config.app_secret = credentials[:app_secret]
3343
+ config.skip_signature_validation = credentials[:skip_signature_validation] || false
3344
+ else
3345
+ config.access_token = ENV["INSTAGRAM_ACCESS_TOKEN"]
3346
+ config.page_id = ENV["INSTAGRAM_PAGE_ID"]
3347
+ config.instagram_account_id = ENV["INSTAGRAM_ACCOUNT_ID"]
3348
+ config.verify_token = ENV["INSTAGRAM_VERIFY_TOKEN"]
3349
+ config.app_id = ENV["INSTAGRAM_APP_ID"]
3350
+ config.app_secret = ENV["INSTAGRAM_APP_SECRET"]
3351
+ config.skip_signature_validation = ENV["INSTAGRAM_SKIP_SIGNATURE_VALIDATION"] == "true"
3352
+ end
3353
+
3354
+ config
3355
+ end
3356
+
3357
+ def valid?
3358
+ access_token.present? && page_id.present? && verify_token.present?
3359
+ end
3360
+
3361
+ # On the Facebook Login path the webhook entry is keyed on the linked page,
3362
+ # not the Instagram account, so that is what an inbound event is checked
3363
+ # against.
3364
+ def account_id
3365
+ page_id
3366
+ end
3367
+
3368
+ def messages_url
3369
+ "#{api_base_url}/#{page_id}/messages"
3370
+ end
3371
+
3372
+ def attachment_upload_url
3373
+ "#{api_base_url}/#{page_id}/message_attachments"
3374
+ end
3375
+
3376
+ def api_base_url
3377
+ FlowChat::Config.instagram.api_base_url
3378
+ end
3379
+
3380
+ def api_headers
3381
+ {
3382
+ "Authorization" => "Bearer #{access_token}",
3383
+ "Content-Type" => "application/json"
3384
+ }
3385
+ end
3386
+ end
3387
+ end
3388
+ end
3389
+ ```
3390
+
3391
+ - [ ] **Step 5: Run the test**
3392
+
3393
+ Run: `ruby -Itest test/unit/instagram/configuration_test.rb`
3394
+ Expected: PASS.
3395
+
3396
+ - [ ] **Step 6: Commit**
3397
+
3398
+ ```bash
3399
+ git add lib/flow_chat/instagram/configuration.rb lib/flow_chat/config.rb test/unit/instagram/configuration_test.rb
3400
+ git commit -m "feat(instagram): configure the account a gateway speaks for"
3401
+ ```
3402
+
3403
+ ---
3404
+
3405
+ ## Task 16: Instagram renderer, client, gateway and choice mapper
3406
+
3407
+ **Goal:** The Instagram platform, differing from Messenger in three ways: the body is always numbered, text is measured in bytes, and the limits come from `Config.instagram`.
3408
+
3409
+ **Files:**
3410
+ - Create: `lib/flow_chat/instagram/renderer.rb`
3411
+ - Create: `lib/flow_chat/instagram/client.rb`
3412
+ - Create: `lib/flow_chat/instagram/gateway/send_api.rb`
3413
+ - Create: `lib/flow_chat/instagram/middleware/choice_mapper.rb`
3414
+ - Create: `test/unit/instagram/renderer_test.rb`
3415
+ - Create: `test/unit/instagram/client_test.rb`
3416
+
3417
+ **Acceptance Criteria:**
3418
+ - [ ] Quick replies and carousels both also carry a numbered list in the body
3419
+ - [ ] A screen with no choices has no numbered list
3420
+ - [ ] Text is split by **bytes**, not characters: a 1000-byte cap with multibyte characters produces more chunks than a naive character count would
3421
+ - [ ] The choice mapper always stores the position map, so a typed number works at every rung
3422
+ - [ ] `Instagram::Gateway::SendApi` subclasses `Meta::MessagingGateway` directly, not `Messenger::Gateway::SendApi`
3423
+
3424
+ **Verify:** `ruby -Itest test/unit/instagram/renderer_test.rb && ruby -Itest test/unit/instagram/client_test.rb` → PASS
3425
+
3426
+ **Steps:**
3427
+
3428
+ - [ ] **Step 1: Write the failing renderer test**
3429
+
3430
+ Create `test/unit/instagram/renderer_test.rb`:
3431
+
3432
+ ```ruby
3433
+ require "test_helper"
3434
+
3435
+ class InstagramRendererTest < Minitest::Test
3436
+ def render(message, choices: nil, media: nil)
3437
+ FlowChat::Instagram::Renderer.new(message, choices: choices, media: media).render
3438
+ end
3439
+
3440
+ # Quick replies and carousels render on mobile only. Without numbers in the
3441
+ # body a user on desktop gets a prompt with no way to answer it.
3442
+ def test_quick_replies_also_number_the_body
3443
+ result = render("Pick one", choices: {"a" => "Alpha", "b" => "Beta"})
3444
+
3445
+ assert_equal :quick_replies, result[0]
3446
+ assert_includes result[1], "1. Alpha"
3447
+ assert_includes result[1], "2. Beta"
3448
+ end
3449
+
3450
+ def test_carousel_also_numbers_the_body
3451
+ choices = (1..14).to_h { |i| ["k#{i}", "Option #{i}"] }
3452
+
3453
+ result = render("Pick one", choices: choices)
3454
+
3455
+ assert_equal :carousel, result[0]
3456
+ assert_includes result[1], "14. Option 14"
3457
+ end
3458
+
3459
+ def test_no_choices_means_no_numbers
3460
+ result = render("Just a message")
3461
+
3462
+ assert_equal "Just a message", result[1]
3463
+ end
3464
+ end
3465
+ ```
3466
+
3467
+ - [ ] **Step 2: Run and watch it fail**
3468
+
3469
+ Run: `ruby -Itest test/unit/instagram/renderer_test.rb`
3470
+ Expected: FAIL with `NameError: uninitialized constant FlowChat::Instagram::Renderer`.
3471
+
3472
+ - [ ] **Step 3: Write the renderer**
3473
+
3474
+ Create `lib/flow_chat/instagram/renderer.rb`. It is `Messenger::Renderer` with two hooks flipped:
3475
+
3476
+ ```ruby
3477
+ module FlowChat
3478
+ module Instagram
3479
+ class Renderer < FlowChat::Messenger::Renderer
3480
+ private
3481
+
3482
+ def limits
3483
+ FlowChat::Config.instagram
3484
+ end
3485
+
3486
+ # Quick replies and carousels are mobile only on Instagram, so the options
3487
+ # are always listed in the body as well. A user on desktop sees the prompt
3488
+ # and nothing tappable, and without the list has no way to reply.
3489
+ def always_number?
3490
+ true
3491
+ end
3492
+ end
3493
+ end
3494
+ end
3495
+ ```
3496
+
3497
+ This is the one place Instagram inherits from Messenger, and it is deliberate: the renderers really are the same algorithm with different constants. The gateways are not, which is why they are siblings.
3498
+
3499
+ - [ ] **Step 4: Write the failing client test**
3500
+
3501
+ Create `test/unit/instagram/client_test.rb`, mirroring `test/unit/messenger/client_test.rb`, plus the byte-splitting case:
3502
+
3503
+ ```ruby
3504
+ # Meta measures Instagram text in bytes, not characters.
3505
+ def test_text_is_split_by_bytes
3506
+ # Each "é" is 2 bytes, so 600 of them is 1200 bytes: over the 1000 cap
3507
+ # even though the character count is not.
3508
+ text = (["é" * 60] * 10).join(" ")
3509
+ assert_operator text.length, :<, 1000
3510
+ assert_operator text.bytesize, :>, 1000
3511
+
3512
+ @client.send_message("igsid_1", text)
3513
+
3514
+ assert_requested(:post, @config.messages_url, times: 2)
3515
+ end
3516
+ ```
3517
+
3518
+ - [ ] **Step 5: Write the client**
3519
+
3520
+ Create `lib/flow_chat/instagram/client.rb`:
3521
+
3522
+ ```ruby
3523
+ module FlowChat
3524
+ module Instagram
3525
+ class Client < FlowChat::Messenger::Client
3526
+ private
3527
+
3528
+ def renderer_class
3529
+ FlowChat::Instagram::Renderer
3530
+ end
3531
+
3532
+ def platform
3533
+ :instagram
3534
+ end
3535
+
3536
+ def limits
3537
+ FlowChat::Config.instagram
3538
+ end
3539
+
3540
+ # Meta: "Message text must be UTF-8 and be 1,000 bytes or less." A
3541
+ # character count would let multibyte text through and be rejected.
3542
+ def measure(string)
3543
+ string.bytesize
3544
+ end
3545
+ end
3546
+ end
3547
+ end
3548
+ ```
3549
+
3550
+ - [ ] **Step 6: Write the gateway and choice mapper**
3551
+
3552
+ Create `lib/flow_chat/instagram/gateway/send_api.rb`:
3553
+
3554
+ ```ruby
3555
+ module FlowChat
3556
+ module Instagram
3557
+ module Gateway
3558
+ # Instagram DMs, on the shared Messenger Platform envelope.
3559
+ #
3560
+ # A sibling of the Messenger gateway rather than a subclass of it: the two
3561
+ # differ in credentials, limits and subscription object, and neither owns
3562
+ # the other.
3563
+ class SendApi < FlowChat::Meta::MessagingGateway
3564
+ def platform
3565
+ :instagram
3566
+ end
3567
+
3568
+ def gateway_name
3569
+ :instagram_send_api
3570
+ end
3571
+
3572
+ def configuration_class
3573
+ FlowChat::Instagram::Configuration
3574
+ end
3575
+
3576
+ def client_class
3577
+ FlowChat::Instagram::Client
3578
+ end
3579
+
3580
+ def renderer_class
3581
+ FlowChat::Instagram::Renderer
3582
+ end
3583
+
3584
+ def self.choice_mapper_class
3585
+ FlowChat::Instagram::Middleware::ChoiceMapper
3586
+ end
3587
+
3588
+ # Confirm against the Meta app dashboard before relying on this. On the
3589
+ # Facebook Login path Meta's own docs were ambiguous about whether these
3590
+ # arrive under "page" or "instagram", which is why it is a hook.
3591
+ def expected_webhook_object
3592
+ "instagram"
3593
+ end
3594
+
3595
+ private
3596
+
3597
+ def configuration_error_class
3598
+ FlowChat::Instagram::ConfigurationError
3599
+ end
3600
+
3601
+ def platform_label
3602
+ "Instagram"
3603
+ end
3604
+ end
3605
+ end
3606
+ end
3607
+ end
3608
+ ```
3609
+
3610
+ Create `lib/flow_chat/instagram/middleware/choice_mapper.rb`:
3611
+
3612
+ ```ruby
3613
+ module FlowChat
3614
+ module Instagram
3615
+ module Middleware
3616
+ class ChoiceMapper < FlowChat::Messenger::Middleware::ChoiceMapper
3617
+ ID_KEY = "instagram.choice_mapping"
3618
+ POSITION_KEY = "instagram.position_mapping"
3619
+
3620
+ private
3621
+
3622
+ def platform_limits
3623
+ FlowChat::Config.instagram
3624
+ end
3625
+
3626
+ # The body always carries numbers here, so a typed number must always
3627
+ # resolve.
3628
+ def always_number?
3629
+ true
3630
+ end
3631
+ end
3632
+ end
3633
+ end
3634
+ end
3635
+ ```
3636
+
3637
+ - [ ] **Step 7: Run the tests**
3638
+
3639
+ Run: `ruby -Itest test/unit/instagram/renderer_test.rb && ruby -Itest test/unit/instagram/client_test.rb`
3640
+ Expected: PASS.
3641
+
3642
+ - [ ] **Step 8: Run the full suite**
3643
+
3644
+ Run: `bundle exec rake test`
3645
+ Expected: PASS, 0 failures.
3646
+
3647
+ - [ ] **Step 9: Commit**
3648
+
3649
+ ```bash
3650
+ git add lib/flow_chat/instagram/ test/unit/instagram/
3651
+ git commit -m "feat(instagram): add the gateway, numbering options for desktop
3652
+
3653
+ Quick replies and carousels render on mobile Instagram only, so the
3654
+ renderer lists the options numbered in the body at every rung and the
3655
+ choice mapper always keeps the positions. Without that a user on a
3656
+ browser sees a prompt with nothing tappable and no way to answer.
3657
+
3658
+ Text is measured in bytes, since Meta caps Instagram messages at 1,000
3659
+ bytes rather than 1,000 characters."
3660
+ ```
3661
+
3662
+ ---
3663
+
3664
+ ## Task 17: Instagram integration test
3665
+
3666
+ **Goal:** A full webhook-to-send cycle on Instagram, including a typed-number reply.
3667
+
3668
+ **Files:**
3669
+ - Create: `test/integration/instagram_integration_test.rb`
3670
+
3671
+ **Acceptance Criteria:**
3672
+ - [ ] A conversation advances via a tapped quick reply
3673
+ - [ ] The same conversation advances via a typed number, proving the desktop path works
3674
+ - [ ] A webhook whose `object` does not match `expected_webhook_object` is ignored with 200
3675
+
3676
+ **Verify:** `ruby -Itest test/integration/instagram_integration_test.rb` → PASS
3677
+
3678
+ **Steps:**
3679
+
3680
+ - [ ] **Step 1: Write the test**
3681
+
3682
+ Create `test/integration/instagram_integration_test.rb`, modelled on `test/integration/messenger_integration_test.rb` from Task 14, with the two reply styles:
3683
+
3684
+ ```ruby
3685
+ def test_typed_number_advances_the_flow
3686
+ run_webhook(text: "Hello")
3687
+ assert_match(/What is your name/, last_sent_prompt)
3688
+
3689
+ run_webhook(text: "Ama")
3690
+ assert_match(/Choose a plan/, last_sent_prompt)
3691
+ assert_match(/1\. Basic/, last_sent_prompt)
3692
+
3693
+ run_webhook(text: "2")
3694
+ assert_match(/you chose pro/, last_sent_prompt)
3695
+ end
3696
+
3697
+ def test_webhook_for_another_object_is_ignored
3698
+ context = run_raw_webhook({"object" => "page", "entry" => []})
3699
+
3700
+ assert_equal :ok, context.controller.last_head_status
3701
+ end
3702
+ ```
3703
+
3704
+ - [ ] **Step 2: Run it**
3705
+
3706
+ Run: `ruby -Itest test/integration/instagram_integration_test.rb`
3707
+ Expected: PASS.
3708
+
3709
+ - [ ] **Step 3: Run the full suite**
3710
+
3711
+ Run: `bundle exec rake test`
3712
+ Expected: PASS, 0 failures.
3713
+
3714
+ - [ ] **Step 4: Commit**
3715
+
3716
+ ```bash
3717
+ git add test/integration/instagram_integration_test.rb
3718
+ git commit -m "test(instagram): drive a conversation by tap and by typed number"
3719
+ ```
3720
+
3721
+ ---
3722
+
3723
+ ## Task 18: Simulator support
3724
+
3725
+ **Goal:** Both platforms selectable in the built-in simulator.
3726
+
3727
+ **Note on scope:** the simulator is not extensible by configuration alone. `lib/flow_chat/simulator/controller.rb:29-70` holds a `configurations` hash, which is the easy part, but `lib/flow_chat/simulator/views/simulator.html.erb` branches on `processor_type` in embedded JavaScript at six points (lines 1109, 1129-1136, 1187-1191, 1212-1214, 1221-1225) and carries per-platform screen chrome (`#whatsapp-screen`, line 941). Both new platforms render as chat bubbles exactly like WhatsApp, so they reuse the WhatsApp screen chrome rather than growing two more copies of it.
3728
+
3729
+ **Files:**
3730
+ - Modify: `lib/flow_chat/simulator/controller.rb:29-70`
3731
+ - Modify: `lib/flow_chat/simulator/views/simulator.html.erb` (the six `processor_type` branches and the screen selector)
3732
+
3733
+ **Acceptance Criteria:**
3734
+ - [ ] Messenger and Instagram appear in the simulator's platform selector
3735
+ - [ ] Both post the `entry[].messaging[]` body shape their gateway parses, with `simulator_mode: true`
3736
+ - [ ] Both render in the chat-bubble screen, not the USSD screen
3737
+ - [ ] A simulated send returns the rendered payload as JSON rather than calling the Send API
3738
+ - [ ] The signed-cookie gate still applies: no valid cookie means no simulator mode
3739
+
3740
+ **Verify:** `bundle exec rake test` → PASS, and loading the simulator page shows five platforms
3741
+
3742
+ **Steps:**
3743
+
3744
+ - [ ] **Step 1: Add the two configurations**
3745
+
3746
+ In `lib/flow_chat/simulator/controller.rb`, add to the `configurations` hash after the `whatsapp` entry:
3747
+
3748
+ ```ruby
3749
+ messenger: {
3750
+ name: "Messenger (Send API)",
3751
+ description: "Facebook Messenger integration using the Send API",
3752
+ processor_type: "messenger",
3753
+ gateway: "send_api",
3754
+ endpoint: "/messenger/webhook",
3755
+ icon: "💬",
3756
+ color: "#0084FF",
3757
+ settings: {
3758
+ user_id: default_phone_number,
3759
+ contact_name: default_contact_name
3760
+ }
3761
+ },
3762
+ instagram: {
3763
+ name: "Instagram (Send API)",
3764
+ description: "Instagram DM integration using the Send API",
3765
+ processor_type: "instagram",
3766
+ gateway: "send_api",
3767
+ endpoint: "/instagram/webhook",
3768
+ icon: "📷",
3769
+ color: "#E1306C",
3770
+ settings: {
3771
+ user_id: default_phone_number,
3772
+ contact_name: default_contact_name
3773
+ }
3774
+ },
3775
+ ```
3776
+
3777
+ - [ ] **Step 2: Introduce one predicate instead of six string comparisons**
3778
+
3779
+ In `simulator.html.erb`, the JS asks `processor_type === 'whatsapp'` in six places to mean "this platform is a chat bubble UI". Replace those comparisons with a single helper defined next to the other state helpers, so adding a sixth platform later is one edit rather than six:
3780
+
3781
+ ```javascript
3782
+ const CHAT_PLATFORMS = ['whatsapp', 'messenger', 'instagram']
3783
+
3784
+ function isChatPlatform(processorType) {
3785
+ return CHAT_PLATFORMS.includes(processorType)
3786
+ }
3787
+ ```
3788
+
3789
+ Then at each of lines 1109, 1130, 1136, 1189, 1212 and 1223, replace the `=== 'whatsapp'` test with `isChatPlatform(...)`. Read each branch before editing: some build the request body and need the per-platform shape from Step 3, not just the shared predicate.
3790
+
3791
+ - [ ] **Step 3: Build the right webhook body per platform**
3792
+
3793
+ The body-building branches (around lines 1187-1191 and 1212-1225) must send the Messenger Platform envelope for the two new platforms, not the WhatsApp one:
3794
+
3795
+ ```javascript
3796
+ function buildMessagingBody(processorType, text) {
3797
+ return {
3798
+ object: processorType === 'instagram' ? 'instagram' : 'page',
3799
+ entry: [{
3800
+ id: state.currentConfig.settings.page_id || 'page_1',
3801
+ messaging: [{
3802
+ sender: { id: state.currentConfig.settings.user_id },
3803
+ recipient: { id: state.currentConfig.settings.page_id || 'page_1' },
3804
+ timestamp: Date.now(),
3805
+ message: { mid: 'mid.' + Date.now(), text: text }
3806
+ }]
3807
+ }],
3808
+ simulator_mode: true
3809
+ }
3810
+ }
3811
+ ```
3812
+
3813
+ Wire `messenger` and `instagram` to this builder. If Task 20 changes Instagram's `expected_webhook_object` to `page`, this `object` expression changes with it.
3814
+
3815
+ - [ ] **Step 4: Point both at the chat screen**
3816
+
3817
+ At line 1136 the screen toggle hides `#whatsapp-screen` unless the platform is WhatsApp. Use `isChatPlatform` so both new platforms show the same bubble screen. Confirm the header label at line 942 reads from config rather than being hardcoded to "WhatsApp"; if it is hardcoded, drive it from `state.currentConfig.name`.
3818
+
3819
+ - [ ] **Step 5: Verify the gate still holds**
3820
+
3821
+ Confirm `simulate?` in `Meta::MessagingGateway` requires both `context["enable_simulator"]` and a valid cookie. Then confirm no test passes with the cookie removed.
3822
+
3823
+ Run: `ruby -Itest test/unit/security_test.rb`
3824
+ Expected: PASS.
3825
+
3826
+ - [ ] **Step 6: Load the page**
3827
+
3828
+ Start the simulator however the project normally does (check `docs/testing.md` for the documented route) and confirm five platforms appear, that selecting Messenger shows the bubble screen, and that sending a message returns a JSON `would_send` payload.
3829
+
3830
+ - [ ] **Step 7: Run the full suite**
3831
+
3832
+ Run: `bundle exec rake test`
3833
+ Expected: PASS, 0 failures.
3834
+
3835
+ - [ ] **Step 8: Commit**
3836
+
3837
+ ```bash
3838
+ git add lib/flow_chat/simulator/
3839
+ git commit -m "feat(simulator): add messenger and instagram
3840
+
3841
+ The view asked processor_type === 'whatsapp' in six places to mean \"this
3842
+ platform draws chat bubbles\". That is now one predicate over a list, so
3843
+ the two new platforms reuse the bubble screen instead of copying it."
3844
+ ```
3845
+
3846
+ ---
3847
+
3848
+ ## Task 19: Documentation
3849
+
3850
+ **Goal:** Both platforms documented to the standard of the existing platform guides.
3851
+
3852
+ **Files:**
3853
+ - Create: `docs/platforms/messenger.md`, `docs/platforms/instagram.md`
3854
+ - Modify: `README.md:26`, `README.md:74`, `README.md:186`, `README.md:236`
3855
+ - Modify: `docs/gateway-context-variables.md`
3856
+
3857
+ **Acceptance Criteria:**
3858
+ - [ ] Each platform guide covers setup, credentials, webhook fields, the choice ladder with its real numbers, media, echoes and coexistence, and limits
3859
+ - [ ] The README platform table gains both gateway classes, platform symbols and rendering summaries
3860
+ - [ ] The README platform-differences table states the real caps: 13 quick replies, 30 via carousel, Instagram mobile only, 1000 bytes
3861
+ - [ ] `docs/gateway-context-variables.md` lists what both gateways set, including `request.msisdn` being nil
3862
+ - [ ] No em-dashes anywhere in the new or edited prose
3863
+
3864
+ **Verify:** `grep -c "—" docs/platforms/messenger.md docs/platforms/instagram.md` → 0 for both
3865
+
3866
+ **Steps:**
3867
+
3868
+ - [ ] **Step 1: Read the model**
3869
+
3870
+ Read `docs/platforms/whatsapp.md` and `docs/platforms/telegram.md` in full. Match their structure and register: dense plain prose, real limits, documented edge cases, no marketing adjectives.
3871
+
3872
+ - [ ] **Step 2: Write the two guides**
3873
+
3874
+ Each guide covers, in this order: what the platform is and which Meta product it uses; credentials and where they come from; webhook setup and the fields to subscribe; a wiring example with `use_gateway`; the choice ladder table with real numbers; media in and out; echoes and what `echo_origin` means for coexistence; the 24 hour window and what happens when a send is rejected; and a limits table.
3875
+
3876
+ For Instagram, state plainly that quick replies and carousels do not render on desktop, and that this is why the options are always numbered in the body.
3877
+
3878
+ - [ ] **Step 3: Update the README**
3879
+
3880
+ Add rows to the platform table at `README.md:74`:
3881
+
3882
+ | Platform | Gateway class | Platform symbol | Rendering |
3883
+ |---|---|---|---|
3884
+ | Messenger | `FlowChat::Messenger::Gateway::SendApi` | `:messenger` | Quick replies, carousel, numbered text |
3885
+ | Instagram | `FlowChat::Instagram::Gateway::SendApi` | `:instagram` | Quick replies, carousel, always numbered |
3886
+
3887
+ Add both to the platform-differences table at `README.md:186`, the intro sentence at `README.md:26`, and the docs index at `README.md:236`.
3888
+
3889
+ - [ ] **Step 4: Update the context-variables doc**
3890
+
3891
+ Add a column or section for both gateways in `docs/gateway-context-variables.md`, noting `request.msisdn` is nil and `request.user_id` holds the PSID or IGSID.
3892
+
3893
+ - [ ] **Step 5: Check the prose**
3894
+
3895
+ Run: `grep -n "—" docs/platforms/messenger.md docs/platforms/instagram.md README.md docs/gateway-context-variables.md`
3896
+ Expected: no output for the new files. Pre-existing em-dashes elsewhere in the README are not this task's business unless they are in a line you edited.
3897
+
3898
+ - [ ] **Step 6: Commit**
3899
+
3900
+ ```bash
3901
+ git add docs/platforms/messenger.md docs/platforms/instagram.md README.md docs/gateway-context-variables.md
3902
+ git commit -m "docs: document messenger and instagram"
3903
+ ```
3904
+
3905
+ ---
3906
+
3907
+ ## Task 20: Verify the two unresolvable facts with the user
3908
+
3909
+ **Goal:** Close the two gaps that cannot be settled from this machine.
3910
+
3911
+ **Files:**
3912
+ - Modify: `lib/flow_chat/instagram/gateway/send_api.rb` (`expected_webhook_object`, if the answer differs)
3913
+ - Modify: `lib/flow_chat/instagram/renderer.rb` (drop the carousel rung, if it reads badly)
3914
+
3915
+ **Acceptance Criteria:**
3916
+ - [ ] The Instagram `expected_webhook_object` matches what the user's Meta app dashboard actually shows
3917
+ - [ ] The Instagram carousel decision is confirmed against a real device, or the rung is dropped
3918
+ - [ ] Any code change from the answers is made and tested
3919
+
3920
+ **Verify:** `bundle exec rake test` → PASS after any change
3921
+
3922
+ **User Verification Required:**
3923
+ Before marking this task complete, you MUST call AskUserQuestion:
3924
+ ```yaml
3925
+ AskUserQuestion:
3926
+ question: "Two things I cannot check from here. In your Meta app dashboard, which webhook object are Instagram messaging events subscribed under, and does the Instagram carousel look acceptable for a plain list of options on a real device?"
3927
+ header: "Verification"
3928
+ options:
3929
+ - label: "object is instagram, carousel is fine"
3930
+ description: "Keep expected_webhook_object as \"instagram\" and keep the carousel rung between 14 and 30 choices"
3931
+ - label: "object is page, carousel is fine"
3932
+ description: "Change expected_webhook_object to \"page\" and keep the carousel rung"
3933
+ - label: "object is instagram, carousel reads badly"
3934
+ description: "Keep the object and drop the carousel rung on Instagram, so above 13 choices goes straight to numbered text"
3935
+ - label: "object is page, carousel reads badly"
3936
+ description: "Change the object to \"page\" and drop the carousel rung on Instagram"
3937
+ ```
3938
+
3939
+ **If the user selects an option indicating rework:** apply the change, re-run the suite, and re-verify with AskUserQuestion again.
3940
+
3941
+ **Steps:**
3942
+
3943
+ - [ ] **Step 1: Ask**
3944
+
3945
+ Call `AskUserQuestion` exactly as above. Do not guess either answer.
3946
+
3947
+ - [ ] **Step 2: Apply the webhook object answer**
3948
+
3949
+ If the answer is `page`, change `expected_webhook_object` in `lib/flow_chat/instagram/gateway/send_api.rb` to `"page"` and update the comment to record that it was confirmed rather than assumed. Update the Instagram integration test's payloads to match.
3950
+
3951
+ - [ ] **Step 3: Apply the carousel answer**
3952
+
3953
+ If the carousel reads badly, override the ladder in `lib/flow_chat/instagram/renderer.rb` so `:carousel` is never chosen:
3954
+
3955
+ ```ruby
3956
+ # Confirmed on a real device: the carousel needs a title per card and a
3957
+ # plain option list has none, so it reads as noise. Above the quick-reply
3958
+ # cap the options go in the body instead.
3959
+ def render
3960
+ return build_attachment if media && choices.blank?
3961
+
3962
+ rung = FlowChat::Meta::ChoiceLadder.rung_for(choice_count, limits)
3963
+ rung = :numbered if rung == :carousel
3964
+
3965
+ case rung
3966
+ when :none then build_text
3967
+ when :quick_replies then build_quick_replies
3968
+ when :numbered then build_numbered
3969
+ end
3970
+ end
3971
+ ```
3972
+
3973
+ Update `test/unit/instagram/renderer_test.rb` to assert the new behavior, replacing `test_carousel_also_numbers_the_body`.
3974
+
3975
+ - [ ] **Step 4: Run the full suite**
3976
+
3977
+ Run: `bundle exec rake test`
3978
+ Expected: PASS, 0 failures.
3979
+
3980
+ - [ ] **Step 5: Update the spec's open items**
3981
+
3982
+ In `docs/superpowers/specs/2026-08-10-messenger-instagram-design.md`, replace the "Open items for implementation" entries 2 and 3 with what was confirmed, so the spec stops claiming they are unknown.
3983
+
3984
+ - [ ] **Step 6: Commit**
3985
+
3986
+ ```bash
3987
+ git add lib/flow_chat/instagram/ test/ docs/superpowers/specs/2026-08-10-messenger-instagram-design.md
3988
+ git commit -m "fix(instagram): settle the webhook object and carousel questions"
3989
+ ```
3990
+
3991
+ ---
3992
+
3993
+ ## Task 21: Confirm the Messenger text cap and close out
3994
+
3995
+ **Goal:** Replace the one assumed constant with a verified one, and confirm the whole branch is green.
3996
+
3997
+ **Files:**
3998
+ - Modify: `lib/flow_chat/config.rb` (`MessengerConfig#max_text_length`, if wrong)
3999
+ - Modify: `docs/superpowers/specs/2026-08-10-messenger-instagram-design.md` (open item 1)
4000
+
4001
+ **Acceptance Criteria:**
4002
+ - [ ] `max_text_length` for Messenger matches Meta's documented cap, verified rather than assumed
4003
+ - [ ] The spec's open item 1 is resolved
4004
+ - [ ] `bundle exec rake test` passes with 0 failures and 0 errors
4005
+ - [ ] `standardrb` (or whatever linter the Rakefile runs) is clean
4006
+
4007
+ **Verify:** `bundle exec rake test` → PASS, 0 failures, 0 errors
4008
+
4009
+ **Steps:**
4010
+
4011
+ - [ ] **Step 1: Verify the cap**
4012
+
4013
+ Fetch Meta's Send API reference for the `message.text` limit. The design assumed 2000 because the reference page would not render during design. If it is different, change `@max_text_length` in `MessengerConfig` and update the client test's long-text fixture so it still crosses the boundary.
4014
+
4015
+ - [ ] **Step 2: Update the spec**
4016
+
4017
+ Replace open item 1 with the confirmed number and its source.
4018
+
4019
+ - [ ] **Step 3: Run everything**
4020
+
4021
+ Run: `bundle exec rake test`
4022
+ Expected: PASS, 0 failures, 0 errors.
4023
+
4024
+ Run: `bundle exec rake -T` to find the lint task, then run it.
4025
+ Expected: clean.
4026
+
4027
+ - [ ] **Step 4: Review the whole diff**
4028
+
4029
+ Run: `git diff feat/coexistence-webhooks...HEAD --stat`
4030
+ Confirm nothing unintended was touched, particularly that `lib/flow_chat/whatsapp/id_generator.rb` is gone and the three configuration classes lost their duplicated registries.
4031
+
4032
+ - [ ] **Step 5: Commit**
4033
+
4034
+ ```bash
4035
+ git add lib/flow_chat/config.rb docs/superpowers/specs/2026-08-10-messenger-instagram-design.md
4036
+ git commit -m "fix(messenger): use the documented text cap"
4037
+ ```
4038
+
4039
+ ---
4040
+
4041
+ ## Spec coverage check
4042
+
4043
+ | Spec section | Task |
4044
+ |---|---|
4045
+ | Meta shared modules | 1, 2 |
4046
+ | NamedConfiguration extraction, all five | 3, 8, 15 |
4047
+ | IdGenerator with configurable cap | 4 |
4048
+ | `to_plain_text` | 5 |
4049
+ | WhatsApp fix 1, lists above 10 | 6 |
4050
+ | WhatsApp fix 2, echo origin | 7 |
4051
+ | Messenger configuration and `Config.messenger` | 8 |
4052
+ | Choice ladder | 9 |
4053
+ | Messenger renderer | 10 |
4054
+ | Messenger client, delivery hooks, splitting | 11 |
4055
+ | `Meta::MessagingGateway`, inbound dispatch, echoes, statuses, context, sessions | 12 |
4056
+ | Messenger gateway and choice mapper | 13 |
4057
+ | Messenger tests, async | 14 |
4058
+ | Instagram configuration | 15 |
4059
+ | Instagram renderer, client, gateway, choice mapper | 16 |
4060
+ | Instagram tests | 17 |
4061
+ | Simulator | 18 |
4062
+ | Docs and README | 19 |
4063
+ | Open items 2 and 3 | 20 |
4064
+ | Open item 1 | 21 |