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,391 @@
1
+ # Facebook Messenger and Instagram DM Support: Design
2
+
3
+ **Date:** 2026-08-10
4
+ **Status:** Implemented on `feat/messenger-instagram`. Two open items below are unverified and flagged in place.
5
+ **Branch context:** builds on `feat/coexistence-webhooks`
6
+
7
+ ## Problem
8
+
9
+ FlowChat ships gateways for USSD, WhatsApp, Telegram, HTTP and Intercom. Two Meta
10
+ messaging surfaces are missing: Facebook Messenger (Page DMs) and Instagram DMs.
11
+ Both run on the Messenger Platform, which shares an envelope and a Send API with
12
+ each other and shares webhook verification and signature validation with the
13
+ WhatsApp Cloud API gateway already in the tree.
14
+
15
+ Adding them by copying `lib/flow_chat/whatsapp/gateway/cloud_api.rb` would put a
16
+ third and fourth copy of the hub-verification and `X-Hub-Signature-256` logic in the
17
+ gem. That logic changed twice recently (`76b141b`, `f4fce7a`), so the copies would
18
+ drift.
19
+
20
+ ## Goals
21
+
22
+ 1. Facebook Messenger and Instagram DMs as first-class platforms, at parity with the
23
+ WhatsApp gateway: gateway, configuration, client, renderer, choice mapping,
24
+ inbound and outbound media, async, instrumentation, simulator, docs and tests.
25
+ 2. One implementation of the Meta webhook plumbing, shared by all three Meta
26
+ platforms.
27
+ 3. Two correctness fixes to the WhatsApp gateway found while designing this.
28
+
29
+ ## Non-Goals
30
+
31
+ - The handover protocol beyond publishing `standby` and `messaging_handovers`.
32
+ - Message tags for sends outside the 24 hour window.
33
+ - Persistent menus and ice breakers.
34
+ - The Instagram API with Instagram Login path (`graph.instagram.com`). Only the
35
+ Facebook Login path is supported.
36
+ - Facebook Page feed or comment events. This is DMs only.
37
+
38
+ ## Verified platform facts
39
+
40
+ Checked against Meta's documentation on 2026-08-10, because the rendering design
41
+ depends on the exact numbers.
42
+
43
+ | Surface | Messenger | Instagram |
44
+ |---|---|---|
45
+ | Quick replies | 13 max, title 20 chars, payload 1000 chars | 13 max, title 20 chars, mobile only, plain text only |
46
+ | Generic template (carousel) | 10 elements, 3 buttons per element, `postback` and `web_url` only, title 80, subtitle 80 | same, mobile app only, not on web |
47
+ | Text length | 2000 chars (NOT VERIFIED, the reference page did not render; confirm during implementation) | under 1000 chars, and 1000 bytes UTF-8 |
48
+ | Send endpoint | `POST /{PAGE_ID}/messages`, also `/me/messages` | same, and no `messaging_type` parameter |
49
+ | Rich text | none | none |
50
+
51
+ Webhook envelope for both: `object` at the top level, then `entry[].messaging[]`,
52
+ where each event carries `sender`, `recipient`, `timestamp` and one event-specific
53
+ key. Messenger delivers under `object: "page"`. Whether Instagram via Facebook Login
54
+ delivers under `page` or `instagram` was ambiguous in the docs, so the expected value
55
+ is a subclass hook to be confirmed against the app dashboard, not a constant baked
56
+ into shared code.
57
+
58
+ Messenger webhook fields: `messages`, `message_deliveries`, `message_echoes`,
59
+ `message_edits`, `message_reactions`, `message_reads`, `messaging_account_linking`,
60
+ `messaging_feedback`, `messaging_game_plays`, `messaging_handovers`,
61
+ `messaging_optins`, `messaging_policy_enforcement`, `messaging_postbacks`,
62
+ `messaging_referrals`, `messaging_seen`, `messenger_template_status_update`,
63
+ `response_feedback`, `send_cart`, `standby`.
64
+
65
+ WhatsApp interactive lists, for the fix below: "up to 10 sections, with up to 10 rows
66
+ for all sections combined", row title 24, row description 72, button text 20.
67
+
68
+ ## Approach
69
+
70
+ A shared base with two thin gateways.
71
+
72
+ `FlowChat::Meta::` holds what all three Meta platforms share.
73
+ `FlowChat::Meta::MessagingGateway` implements the `entry[].messaging[]` envelope once.
74
+ `Messenger::Gateway::SendApi` and `Instagram::Gateway::SendApi` each subclass it and
75
+ override four hooks: platform symbol, configuration class, renderer class, and the
76
+ account-id check that corresponds to the `phone_number_id` comparison at
77
+ `cloud_api.rb:224`.
78
+
79
+ Instagram subclasses the shared base rather than the Messenger gateway. The two are
80
+ siblings, not parent and child, and making `Instagram` depend on the `Messenger`
81
+ namespace would misstate the relationship.
82
+
83
+ Rejected alternatives:
84
+
85
+ - **One gateway serving both platforms, branching on the webhook `object`.** Fewest
86
+ files, and it mirrors how Meta frames the Messenger Platform, but the class would
87
+ branch on platform for tokens, character limits, rendering capability and event
88
+ types, and the configuration's `valid?` would depend on which half was configured.
89
+ - **Copy the WhatsApp gateway with a `platform:` constructor argument.** Cheapest to
90
+ write and touches nothing existing, at the cost of a third and fourth copy of
91
+ recently changed signature logic.
92
+
93
+ ## Components
94
+
95
+ ### 1. `FlowChat::Meta` (new)
96
+
97
+ - `lib/flow_chat/meta/webhook_verification.rb`: the `hub.mode` / `hub.verify_token` /
98
+ `hub.challenge` exchange, including the empty-token guard from `cloud_api.rb:89`
99
+ (a configuration with no verify token must verify nothing).
100
+ - `lib/flow_chat/meta/signature_validation.rb`: `X-Hub-Signature-256` HMAC over the
101
+ raw body, using `FlowChat::Security.secure_compare`, with the
102
+ `skip_signature_validation` escape and the "app_secret required" error.
103
+
104
+ `Whatsapp::Gateway::CloudApi` loses its private `handle_verification` and
105
+ `valid_webhook_signature?` and includes these instead. Behavior is unchanged, so
106
+ `test/unit/whatsapp/gateway/cloud_api_test.rb` is the regression net.
107
+
108
+ ### 2. `FlowChat::NamedConfiguration` (new)
109
+
110
+ The named-configuration registry (`register`, `get`, `exists?`, `configuration_names`,
111
+ `clear_all!`, `register_as`) is currently duplicated verbatim in
112
+ `whatsapp/configuration.rb:46`, `telegram/configuration.rb:46` and
113
+ `intercom/configuration.rb:53`. The two new platforms would make five copies.
114
+
115
+ Extracted to one module, with **per-class storage**. The existing copies use a
116
+ `@@configurations` class variable; a shared module using `@@` would give every
117
+ platform one merged registry, which is a bug. The module keys storage per including
118
+ class instead.
119
+
120
+ All five classes migrate onto it. Telegram and Intercom have configuration test
121
+ suites. WhatsApp's configuration has no direct test, so its migration leans on the
122
+ gateway tests.
123
+
124
+ ### 3. `Messenger::Configuration` and `Instagram::Configuration` (new)
125
+
126
+ `Messenger::Configuration` carries `page_id`, `access_token`, `verify_token`,
127
+ `app_id`, `app_secret`, `skip_signature_validation`. `Instagram::Configuration`
128
+ carries the same attributes, including `app_id` and `app_secret`, and adds
129
+ `instagram_account_id` alongside the linked `page_id`.
130
+
131
+ Both follow `whatsapp/configuration.rb:29`: a `from_credentials` reading `messenger:`
132
+ or `instagram:` from Rails credentials, falling back to `MESSENGER_*` or
133
+ `INSTAGRAM_*` environment variables. `valid?` requires access token, account id and
134
+ verify token.
135
+
136
+ `app_id` is load-bearing here, not decorative: it is what classifies echoes below.
137
+
138
+ `FlowChat::Config.messenger` and `FlowChat::Config.instagram` join
139
+ `FlowChat::Config.whatsapp`, each carrying `api_base_url` plus that platform's text
140
+ and choice limits as named constants rather than literals in the renderer.
141
+
142
+ ### 4. Inbound: `Messenger::Gateway::SendApi` (new)
143
+
144
+ Walks `entry[].messaging[]` and dispatches on which key each event carries, following
145
+ the rule established by `27fd234`: model messaging, publish everything else.
146
+
147
+ | Event key | Treatment |
148
+ |---|---|
149
+ | `message` with `is_echo` | Published with a derived origin. Never drives a flow. |
150
+ | `message` | Drives the flow. Input from `text`, or `quick_reply.payload`, or the attachment caption. |
151
+ | `postback` | Drives the flow. Input from `payload`. Covers carousel buttons, Get Started and ice breakers. |
152
+ | `delivery`, `read` | `MESSAGE_STATUS`, mirroring `handle_statuses` at `cloud_api.rb:276`. |
153
+ | anything else | `WEBHOOK_RECEIVED` with the field name and the whole value. |
154
+
155
+ Two behaviors carry over from the WhatsApp gateway deliberately:
156
+
157
+ - The `flow_ran` single-flow guard (`cloud_api.rb:150`). Only one event per delivery
158
+ can drive a flow, because only one can own the HTTP response.
159
+ - The ordering fix from `f17de9c`. Deliveries and reads are handled **before** the
160
+ flow slot is claimed, so a receipt arriving ahead of a message in the same batch
161
+ cannot spend the slot and drop the message.
162
+
163
+ Context values set:
164
+
165
+ | Key | Value |
166
+ |---|---|
167
+ | `request.id`, `request.user_id` | `sender.id`, the PSID or IGSID |
168
+ | `request.msisdn` | `nil`. Neither platform exposes a phone number. |
169
+ | `request.message_id` | `mid` |
170
+ | `request.platform` | `:messenger` or `:instagram` |
171
+ | `request.gateway` | `:messenger_send_api` or `:instagram_send_api` |
172
+ | `request.timestamp`, `request.body` | as WhatsApp sets them |
173
+ | `messenger.page.id` / `instagram.account.id` | the receiving account |
174
+ | `messenger.client` / `instagram.client` | the platform client, for out-of-band sends |
175
+ | `request.media` | attachments, normalized. `file` maps to `:document` to match the existing media contract. |
176
+
177
+ Inbound attachments are simpler than WhatsApp's. Meta puts a direct CDN URL in
178
+ `attachments[].payload.url`, so there is no media-id lookup step. Those URLs are
179
+ signed and expire, which the existing `Media` caveat about eager `download` already
180
+ covers.
181
+
182
+ ### 5. Echoes and coexistence
183
+
184
+ An echo is not noise. `message_echoes` reports every message sent on the thread,
185
+ including replies typed by a human in Page Inbox and messages sent by another app.
186
+ An app needs that fact, typically to stop the bot while a human is handling the
187
+ conversation.
188
+
189
+ `app_id` distinguishes the cases, so the gateway derives `echo_origin` before
190
+ publishing:
191
+
192
+ | Condition | `echo_origin` |
193
+ |---|---|
194
+ | `app_id` equals the configured `app_id` | `:self` |
195
+ | `app_id` present and different | `:other_app` |
196
+ | `app_id` absent | `:human_agent` |
197
+
198
+ Published through the existing `WEBHOOK_RECEIVED` event with `field: "message_echoes"`
199
+ and the whole raw value, plus the derived `echo_origin`. This keeps one event
200
+ vocabulary and matches how WhatsApp publishes coexistence echoes, while sparing the
201
+ application from having to know Meta's `app_id` semantics.
202
+
203
+ Echoes never drive a flow. An echo of our own send that drove a flow would loop.
204
+
205
+ ### 6. Outbound: clients
206
+
207
+ `Messenger::Client` and `Instagram::Client` expose
208
+ `send_message(recipient_id, text, choices:, media:)` and `upload_media`, wrapped in
209
+ the existing `report_delivery_failure` helper so `on_delivery_failure` and
210
+ `on_delivery_success` fire as they do at `cloud_api.rb:445`.
211
+
212
+ `platform_message_id_from` reads `result["message_id"]`. The Send API returns
213
+ `{recipient_id, message_id}`, flatter than WhatsApp's `messages[0].id`.
214
+
215
+ Sends outside the 24 hour messaging window are not modelled. The send is attempted,
216
+ and Meta's rejection travels the existing delivery-failure path like any other send
217
+ error. No window tracking and no automatic tagging: the gateway would be holding
218
+ state and guessing at policy the application owns.
219
+
220
+ ### 7. Outbound: renderers and the choice ladder
221
+
222
+ Neither platform supports rich text, so a third text conversion is needed.
223
+ `Renderers::MarkdownSupport` currently offers `to_html` (used by Telegram) and
224
+ WhatsApp implements its own `to_whatsapp`. A shared `to_plain_text` is added to
225
+ `MarkdownSupport`: strip emphasis, render `ul` as bullets and `ol` as numbers, render
226
+ links as `text (url)`, decode entities.
227
+
228
+ The ladder:
229
+
230
+ | Choices | Messenger | Instagram |
231
+ |---|---|---|
232
+ | 0 | text, split at 2000 chars | text, split at 1000 bytes |
233
+ | 1 to 13 | quick replies, title 20, payload is the generated id | quick replies **and** a numbered list in the body |
234
+ | 14 to 30 | carousel, 10 elements by 3 postback buttons, element title 80, button title 20 | carousel **and** a numbered list in the body |
235
+ | above 30 | numbered list in the body, typed number accepted | same |
236
+
237
+ Instagram always carries the numbered list because both of its interactive surfaces
238
+ are mobile only. Without it, a user on desktop Instagram receives a prompt with no
239
+ selectable options and no way to answer, which is a dead-end turn. With it, mobile
240
+ users tap and desktop users type.
241
+
242
+ `Whatsapp::IdGenerator` is extracted to `FlowChat::IdGenerator` with a configurable
243
+ maximum length, since the quick-reply payload cap is 1000 rather than WhatsApp's 256.
244
+
245
+ ### 8. Choice mapping
246
+
247
+ A `Middleware::ChoiceMapper` per platform, mapping payloads back to original choice
248
+ keys exactly as `whatsapp/middleware/choice_mapper.rb` does. Each gateway's
249
+ `configure_middleware_stack` inserts it, as `cloud_api.rb:56` does.
250
+
251
+ The mapper stores one mapping per turn whose keys depend on the rung the renderer
252
+ used. On the quick-reply and carousel rungs the keys are generated ids, as WhatsApp
253
+ does today. On the numbered rung the keys are the position strings `"1"`, `"2"` and
254
+ so on, following `Ussd::Middleware::ChoiceMapper`. Both resolve to the original choice
255
+ key, so the flow never sees the difference.
256
+
257
+ On Instagram, where the body carries a numbered list *and* quick replies or a
258
+ carousel, both key sets apply to the same turn. They are stored as **two separate
259
+ maps**, not merged into one, and resolved generated-id first with the position map as
260
+ the fallback.
261
+
262
+ Merging them would be a bug. `IdGenerator#normalize_label` keeps `\w`, which includes
263
+ digits, so a choice labelled `"1"` generates the id `"1"`. Given
264
+ `{"a" => "2", "b" => "1"}`, position `"1"` means choice `a` while generated id `"1"`
265
+ means choice `b`, and a single hash would silently lose one. Two maps with a defined
266
+ precedence make a tap and a typed number resolve independently and correctly.
267
+
268
+ ### 9. Sessions
269
+
270
+ `platform_default_identifier` (`session/middleware.rb:93`) currently returns
271
+ `:msisdn` for `:whatsapp` and `:request_id` for everything else. Both new platforms
272
+ are added, returning `:user_id`, so sessions key on the PSID or IGSID explicitly
273
+ rather than relying on `request.id` happening to hold it.
274
+
275
+ ### 10. Async, instrumentation, simulator
276
+
277
+ `GatewayAsyncSupport` is included in the base Messenger gateway, so Instagram
278
+ inherits it, and `async_supported?` stays true. The Factory pattern needs no changes,
279
+ so `GenericAsyncJob` covers both platforms unmodified.
280
+
281
+ No new instrumentation event constants. `MESSAGE_RECEIVED`, `MESSAGE_SENT`,
282
+ `MESSAGE_DELIVERY_FAILED`, `MESSAGE_STATUS`, `WEBHOOK_VERIFIED`, `WEBHOOK_FAILED` and
283
+ `WEBHOOK_RECEIVED` already carry `platform` and `gateway` in their payloads.
284
+
285
+ Both platforms are wired into the simulator, reusing the `simulator_mode` flag and
286
+ signed-cookie gating that `simulate?` (`cloud_api.rb:508`) implements.
287
+
288
+ `Messenger::ConfigurationError` and `Instagram::ConfigurationError` mirror
289
+ `Whatsapp::ConfigurationError`.
290
+
291
+ ## WhatsApp fixes
292
+
293
+ Both found while designing the choice ladder, both in scope for this work.
294
+
295
+ ### Fix 1: interactive lists above 10 choices are rejected
296
+
297
+ `Whatsapp::Renderer#build_list_message` (`renderer.rb:186`) slices more than 10
298
+ choices into multiple sections titled `1-10`, `11-20` and so on. Meta caps a list at
299
+ 10 rows for all sections combined, so a 25-choice payload is rejected. The section
300
+ titles read like pagination, but nothing is paged: there is no second message and no
301
+ stored offset.
302
+
303
+ Corrected ladder, consistent with the new platforms:
304
+
305
+ | Choices | Rendering |
306
+ |---|---|
307
+ | 3 or fewer | interactive buttons, title truncated to 20 |
308
+ | 4 to 10 | interactive list, single section, title 24 and description 72 |
309
+ | above 10 | numbered list in the body, typed number accepted |
310
+
311
+ Row title and description truncation in the current code is already correct at 24 and
312
+ 72, so only count handling changes. `test/unit/whatsapp/renderer_test.rb:209` asserts
313
+ the three-section payload and is rewritten to assert the fallback.
314
+
315
+ Paged list messages were considered and rejected for now. They work, but they need a
316
+ "More" row, a stored offset and a turn per page, and the numbered fallback gives all
317
+ four platforms one mechanism with no new state.
318
+
319
+ ### Fix 2: WhatsApp echoes gain a derived origin
320
+
321
+ `handle_unmodelled_field` (`cloud_api.rb:309`) publishes coexistence echoes with the
322
+ raw value. It derives `echo_origin` the same way as above, so both platforms report a
323
+ human takeover identically.
324
+
325
+ ## Testing
326
+
327
+ `test/unit/messenger/` and `test/unit/instagram/` mirroring `test/unit/whatsapp/`:
328
+ gateway, client, renderer, configuration, choice mapper. Plus
329
+ `test/integration/messenger_integration_test.rb` and the Instagram equivalent,
330
+ modelled on `whatsapp_integration_test.rb`.
331
+
332
+ Webhook fixtures covering: text, quick-reply reply, postback, attachment, self echo,
333
+ human-agent echo, delivery, read, and an unmodelled field.
334
+
335
+ Regression coverage for the shared extractions: existing WhatsApp gateway tests for
336
+ `Meta::` verification and signature validation, existing Telegram and Intercom
337
+ configuration tests for `NamedConfiguration`, `test/unit/whatsapp/id_generator_test.rb`
338
+ moved and extended for the configurable maximum length, and the rewritten WhatsApp
339
+ renderer test for the list fix.
340
+
341
+ ## Implementation sequence
342
+
343
+ The work is large enough to want an order. Each phase leaves the tree green and is
344
+ reviewable on its own.
345
+
346
+ 1. **Shared extractions and WhatsApp fixes.** `Meta::WebhookVerification`,
347
+ `Meta::SignatureValidation`, `NamedConfiguration`, `FlowChat::IdGenerator`,
348
+ `to_plain_text`, and both WhatsApp fixes. Touches only existing code and existing
349
+ tests, and is independently valuable.
350
+ 2. **Messenger.** `Meta::MessagingGateway` plus the Messenger configuration, client,
351
+ renderer, choice mapper, gateway, session identifier, simulator, docs and tests.
352
+ 3. **Instagram.** The subclass and its configuration, client and renderer, which is
353
+ mostly limits and the always-numbered body, plus docs and tests.
354
+
355
+ Phase 1 landing first means phases 2 and 3 build on shared code that is already
356
+ proven by the existing suites rather than introducing it and its first consumer at
357
+ once.
358
+
359
+ ## Documentation
360
+
361
+ New `docs/platforms/messenger.md` and `docs/platforms/instagram.md`. Updates to the
362
+ README platform table (`README.md:74`), the platform-differences table
363
+ (`README.md:186`), the intro sentence listing platforms (`README.md:26`) and the docs
364
+ index (`README.md:236`). `docs/gateway-context-variables.md` gains both gateways.
365
+
366
+ Docs follow the established register: dense plain prose, real limits and edge cases,
367
+ no marketing adjectives, no em-dashes.
368
+
369
+ ## Open items for implementation
370
+
371
+ 1. **Resolved, negatively.** Meta states no text limit for Messenger on any current
372
+ reference page, unlike Instagram's documented 1,000 bytes. 2000 stays as the
373
+ long-cited figure, with a comment recording that it is unverified. The client
374
+ splits at this value rather than truncating, so being wrong low costs an extra
375
+ message and being wrong high gets a send rejected.
376
+ 2. **STILL OPEN, must be checked before Instagram goes live.** Whether Instagram via
377
+ Facebook Login delivers webhooks under `object: "page"` or `object: "instagram"`.
378
+ The gateway currently expects `"instagram"`. A wrong value drops every delivery and
379
+ answers 200, so the symptom is a bot that receives nothing while the dashboard
380
+ reports success. `Meta::MessagingGateway` logs a warning naming the mismatch, so a
381
+ single production delivery reveals the answer. One-line fix in
382
+ `Instagram::Gateway::SendApi#expected_webhook_object` plus the integration test
383
+ payloads.
384
+ 3. **STILL OPEN, cosmetic only.** Whether the Instagram carousel reads acceptably for
385
+ a plain option menu, whose cards are titled `"Options 1 to 3"` because a
386
+ generic-template card requires a title and a menu has no natural one. Mobile-only
387
+ question: both of Instagram's interactive surfaces render on mobile alone, which is
388
+ why the renderer always numbers the options in the body as well. Nothing is broken
389
+ on desktop either way, so this cannot cause a failure, only an ugly card. If it
390
+ reads badly, override the ladder in `Instagram::Renderer` so above 13 choices goes
391
+ straight to numbered text.