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,168 @@
1
+ # Gateway context variables
2
+
3
+ Every gateway parses its platform's webhook into a common set of context values. Flows and middleware read these instead of platform-specific request shapes, which is what lets one flow run everywhere. This document lists what each gateway sets.
4
+
5
+ ## All context variables
6
+
7
+ | Variable | USSD Nalo | HTTP Simple | WhatsApp Cloud API | Messenger Send API | Instagram Send API | Telegram Bot API | Intercom API | Description |
8
+ |----------|-----------|-------------|-------------------|--------------------|--------------------|------------------|--------------|-------------|
9
+ | **Common** |
10
+ | `request.id` | Session id | From user_params | Phone number | PSID | IGSID | Chat id | Conversation id | Session or conversation identifier |
11
+ | `request.user_id` | = msisdn | From user_params | Phone number | PSID | IGSID | Telegram user id | Contact id | Stable per-user identifier |
12
+ | `request.user_name` | none | optional | optional | none | none | First and last name | optional | Sender display name |
13
+ | `request.username` | none | none | none | none | none | Telegram @username | none | Telegram username |
14
+ | `request.msisdn` | set | optional | set | nil | nil | none | optional | E.164 phone number |
15
+ | `request.email` | none | optional | none | none | none | none | optional | User email |
16
+ | `request.message_id` | UUID | UUID | WhatsApp id | `mid` | `mid` | Telegram msg id | optional | Message identifier |
17
+ | `request.timestamp` | Current | Current | Current | Current | Current | From message | Current | ISO8601 timestamp |
18
+ | `request.gateway` | `:nalo` | `:http_simple` | `:whatsapp_cloud_api` | `:messenger_send_api` | `:instagram_send_api` | `:telegram_bot_api` | `:intercom_api` | Gateway symbol |
19
+ | `request.platform` | `:ussd` | `:http` | `:whatsapp` | `:messenger` | `:instagram` | `:telegram` | `:intercom` | Platform symbol |
20
+ | `request.body` | set | set | set | set | set | set | set | Raw request body, string keys |
21
+ | `request.input` | Text | Text | Text (note 1) | Text (note 4) | Text (note 4) | Text (note 2) | Text or nil (note 3) | The turn's text |
22
+ | **Structured attachments** |
23
+ | `request.location` | none | none | set | set | set | set | none | Location payload |
24
+ | `request.media` | none | via `media_url` | set | set | set | set | set (may be several) | Media metadata |
25
+ | `request.contact` | none | none | none | none | none | set | none | Contact payload |
26
+ | **WhatsApp** |
27
+ | `whatsapp.business.phone_number` | | | E.164 business number | | | | | |
28
+ | `whatsapp.business.phone_number_id` | | | WhatsApp phone number id | | | | | |
29
+ | `whatsapp.client` | | | client instance | | | | | |
30
+ | **Messenger** |
31
+ | `messenger.account.id` | | | | Page id | | | | |
32
+ | `messenger.client` | | | | client instance | | | | |
33
+ | **Instagram** |
34
+ | `instagram.account.id` | | | | | Page id or Instagram account id, whichever the webhook named | | | |
35
+ | `instagram.client` | | | | | client instance | | | |
36
+ | **Telegram** |
37
+ | `telegram.client` | | | | | | client instance | | |
38
+ | `telegram.chat_type` | | | | | | private, group, supergroup, channel | | |
39
+ | `telegram.callback_query_id` | | | | | | on callbacks | | |
40
+ | `telegram.original_message_id` | | | | | | on callbacks | | |
41
+ | **HTTP** |
42
+ | `http.method` | | GET or POST | | | | | | |
43
+ | `http.path` | | Request path | | | | | | |
44
+ | `http.user_agent` | | User agent header | | | | | | |
45
+ | **Intercom** |
46
+ | `intercom.client` | | | | | | | client instance | |
47
+ | `intercom.topic` | | | | | | | Webhook event type | |
48
+
49
+ Notes on `request.input`:
50
+
51
+ 1. WhatsApp: the message text, a media caption, or a button/list reply id. `""` for a structured turn (location, media, contact) that carries no text.
52
+ 2. Telegram: the message text, callback data, or a media caption. `""` for a structured turn with no text.
53
+ 3. Intercom: the message text or body, or `""`/`nil` for turns without text.
54
+ 4. Messenger and Instagram: the message text, a media caption, a quick-reply payload, or a postback payload. `""` for a turn with no text.
55
+
56
+ `request.msisdn` is `nil` on both Messenger and Instagram: neither platform exposes a phone number, only a PSID or IGSID scoped to the app and the connected account. Use `request.user_id` (or `app.user_id`) as the durable per-user identifier; sessions key on it by default for both platforms.
57
+
58
+ 5. Messenger and Instagram set `request.location`, not `request.media`, for a shared location: the gateway inspects the attachment's `type` and routes a `location` attachment to `request.location` with latitude, longitude, and name, the same as WhatsApp and Telegram. Every other attachment type still sets `request.media`.
59
+
60
+ `context.input` is always plain text. There are no `"$media$"`/`"$location$"`/`"$contact$"` sentinel values: a structured turn with no text sets `input` to `""` and carries its payload on `request.media`, `request.location`, or `request.contact`. In flows, read `app.input` (a `FlowChat::Input`) or its accessors, described below.
61
+
62
+ ## Reading the turn in a flow
63
+
64
+ Prefer the accessors on `app` over reading context keys directly. Every turn is a `FlowChat::Input` value object with two independent axes: text and an optional attachment. Text and media can arrive together (a captioned photo); location and contact arrive on their own.
65
+
66
+ ```ruby
67
+ class MyFlow < FlowChat::Flow
68
+ def start
69
+ # Identity and platform, available on every platform.
70
+ user_id = app.user_id
71
+ platform = app.platform
72
+ msisdn = app.msisdn
73
+
74
+ # The turn's text. Always a string, "" when the turn carried no text.
75
+ message = app.text
76
+
77
+ # Branch on the attachment kind, not on a magic input value.
78
+ case app.attachment_type
79
+ when :media
80
+ app.media.each do |item| # always a list; iterate so you never drop extra attachments
81
+ item.type # canonical: :image, :video, :audio, :document, :sticker
82
+ item.raw_type # platform-native: :photo or :voice on Telegram
83
+ item.mime_type
84
+ item.filename
85
+ link = item.url # a fetchable URL, or nil
86
+ bytes = item.download # the raw file bytes, or nil
87
+ end
88
+ when :location
89
+ lat = app.location[:latitude]
90
+ lng = app.location[:longitude]
91
+ when :contact
92
+ name = app.contact[:name]
93
+ end
94
+ end
95
+ end
96
+ ```
97
+
98
+ Accessors, all shortcuts to the `app.input` value object:
99
+
100
+ - `app.text`: the turn's text, or the caption sent with an attachment. Always a string, `""` when there is no text.
101
+ - `app.attachment_type`: `:media`, `:location`, `:contact`, or `nil`, the discriminator to branch on.
102
+ - `app.attachment`: the payload of `attachment_type` (the media list, the location hash, the contact hash, or `nil`).
103
+ - `app.media`: always an `Array<FlowChat::Media>` (empty when none), a list even on single-media platforms, so you iterate uniformly.
104
+ - `app.location`: the location hash, or `nil`.
105
+ - `app.contact`: the shared contact card hash, or `nil`.
106
+ - `app.contact_name`: the sender's display name (distinct from a shared contact card).
107
+
108
+ ## Media
109
+
110
+ WhatsApp, Messenger, Instagram, Telegram, Intercom, and HTTP set `request.media` for inbound media (USSD is text-only and never sets it). WhatsApp, Messenger, Instagram, and Telegram carry a single item; Intercom may carry several, one per attachment. HTTP callers submit inbound media through the `media_url` request param, with optional `media_type` and `mime_type`.
111
+
112
+ A `FlowChat::Media` item's `type` is a normalized value; `raw_type` is the platform-native value. The normalization maps `:photo` to `:image` and `:voice` to `:audio`, so `type` is one of `:image`, `:video`, `:audio`, `:document`, `:sticker`.
113
+
114
+ | Media type | WhatsApp | Messenger / Instagram | Telegram | Fields |
115
+ |------------|----------|------------------------|----------|--------|
116
+ | `:image` (Telegram `:photo`) | yes | yes | yes | id or file_id, mime_type, width, height |
117
+ | `:video` | yes | yes | yes | id or file_id, mime_type, duration, width, height |
118
+ | `:audio` | yes | yes | yes | id or file_id, mime_type, duration, title, performer |
119
+ | `:voice` | no | no | yes | file_id, mime_type, duration |
120
+ | `:document` (Messenger/Instagram `file`) | yes | yes | yes | id or file_id, mime_type, filename |
121
+ | `:sticker` | yes | not confirmed | yes | id or file_id, emoji, set_name, is_animated |
122
+
123
+ Messenger and Instagram attachments carry only `type` and `url`; there is no separate mime_type, filename, width, height, or duration, because the gateway does not call a lookup API the way WhatsApp does for a media id. `item.url` resolves a fetchable URL per platform (WhatsApp `get_media_url`, Telegram `getFile`, Messenger/Instagram/Intercom/HTTP use the direct URL already on the attachment). `item.download` returns the raw bytes.
124
+
125
+ Meta routes a shared location to `request.location`, not `request.media`, the same as WhatsApp and Telegram - a location share is not a `FlowChat::Media` item on any of these platforms.
126
+
127
+ ### Inspecting attachments in validate and transform
128
+
129
+ The `FlowChat::Input` object behaves like its text for string operations, so validators and transforms read naturally, and it also exposes the attachment:
130
+
131
+ ```ruby
132
+ app.screen(:photo) do |prompt|
133
+ prompt.ask "Send your ID photo",
134
+ validate: ->(input) { "Please attach a photo" unless input.media.any? }
135
+ end
136
+
137
+ app.screen(:name) do |prompt|
138
+ prompt.ask "Your name?", transform: ->(input) { input.strip.titleize }
139
+ end
140
+ ```
141
+
142
+ A turn counts as answered (`input.submitted?`) when it has text or an attachment, so a caption-less photo still satisfies a screen.
143
+
144
+ ### What a screen should return
145
+
146
+ `prompt.ask` and `prompt.select` return a string (the text, or your `transform`'s result), and that string is what gets stored as the screen's answer. Return those from screen blocks.
147
+
148
+ `prompt.user_input` is the raw `FlowChat::Input` object, useful inside a `validate` or `transform` when you want the attachment, but avoid returning it as a screen's value:
149
+
150
+ ```ruby
151
+ # Good: persists a string.
152
+ name = app.screen(:name) { |prompt| prompt.ask "Your name?" }
153
+
154
+ # Avoid: persists the whole Input object into the session store.
155
+ raw = app.screen(:raw) { |prompt| prompt.user_input }
156
+ ```
157
+
158
+ Whatever a screen returns is stored in the session and serialized by the store (`CacheSessionStore` uses `Marshal`). A `FlowChat::Media` serializes without its live platform client, so a media object deserialized from the session has no client and its `url` and `download` return `nil` rather than raising. Fetch media during the turn it arrives, while the client is present; do not rely on downloading it from a stored answer on a later turn.
159
+
160
+ ### Lower-level access
161
+
162
+ The raw request hashes remain available if you need them, but prefer the accessors above:
163
+
164
+ ```ruby
165
+ app.context["request.media"] # raw Hash, or Array for Intercom
166
+ app.context["request.location"]
167
+ app.context["request.contact"]
168
+ ```
@@ -0,0 +1,159 @@
1
+ # Building a gateway
2
+
3
+ A gateway adapts one messaging platform to FlowChat. It is the first and last layer of the middleware stack: it parses the platform's inbound webhook into normalized context values on the way in, and renders the flow's output back to the platform on the way out. FlowChat ships gateways for USSD (Nalo), WhatsApp, Telegram, HTTP, and Intercom; write your own to support anything else.
4
+
5
+ ## The contract
6
+
7
+ A gateway is a middleware object. It takes the next app in its constructor and implements `call(context)`:
8
+
9
+ ```ruby
10
+ module MyCompany
11
+ module Sms
12
+ module Gateway
13
+ class Twilio
14
+ def initialize(app, config = nil)
15
+ @app = app
16
+ @config = config
17
+ end
18
+
19
+ def call(context)
20
+ @context = context
21
+ controller = context.controller
22
+ params = controller.request.params
23
+
24
+ # 1. Parse the inbound webhook into normalized context values.
25
+ context["request.id"] = params["MessageSid"]
26
+ context["request.msisdn"] = FlowChat::PhoneNumberUtil.to_e164(params["From"])
27
+ context["request.user_id"] = context["request.msisdn"]
28
+ context["request.message_id"] = SecureRandom.uuid
29
+ context["request.timestamp"] = Time.current.iso8601
30
+ context["request.gateway"] = :twilio
31
+ context["request.platform"] = :sms
32
+ context["request.body"] = params.to_h.transform_keys(&:to_s)
33
+ context.input = params["Body"].presence || ""
34
+
35
+ # 2. Run the rest of the stack. It returns [type, prompt, choices, media].
36
+ type, prompt, choices, _media = @app.call(context)
37
+
38
+ # 3. Render the result back to the platform.
39
+ message = render(prompt, choices)
40
+ send_sms(message, to: context["request.msisdn"])
41
+ end
42
+
43
+ private
44
+
45
+ def render(prompt, choices)
46
+ # Turn prompt + choices into whatever the platform expects.
47
+ end
48
+
49
+ def send_sms(message, to:)
50
+ # Call the platform API.
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ ```
57
+
58
+ `@app.call(context)` returns a four-element array: `[type, prompt, choices, media]`. `type` is `:prompt` while the conversation continues and `:terminal` when it has ended. `prompt` is the message text, `choices` is a hash of choice keys to labels (or `nil`), and `media` is an outbound media hash (or `nil`).
59
+
60
+ Use the gateway with `use_gateway`, passing any constructor arguments after the class:
61
+
62
+ ```ruby
63
+ processor = FlowChat::Processor.new(self) do |config|
64
+ config.use_gateway MyCompany::Sms::Gateway::Twilio, sms_config
65
+ config.use_session_store FlowChat::Session::CacheSessionStore
66
+ end
67
+ ```
68
+
69
+ ## Context values to set
70
+
71
+ The session middleware and `FlowChat::App` read normalized keys off the context. Set the ones your platform can provide:
72
+
73
+ | Key | Purpose |
74
+ |---|---|
75
+ | `request.id` | The platform's session or conversation id. |
76
+ | `request.user_id` | A stable per-user id (used by `use_durable_sessions`). |
77
+ | `request.msisdn` | The user's phone number in E.164, when available. |
78
+ | `request.message_id` | A unique id for this message. |
79
+ | `request.timestamp` | ISO8601 time of the message. |
80
+ | `request.gateway` | Your gateway's symbol, for example `:twilio`. |
81
+ | `request.platform` | The platform symbol, for example `:sms`. |
82
+ | `request.body` | The raw request payload, with string keys. |
83
+ | `context.input` | The turn's text (a caption or `""` when the turn carries only an attachment). |
84
+ | `request.media` / `request.location` / `request.contact` | Structured attachments, when present. |
85
+
86
+ The full list and how each existing gateway populates it is in [gateway-context-variables.md](gateway-context-variables.md).
87
+
88
+ ## Adding platform middleware
89
+
90
+ If your platform needs its own middleware (USSD adds pagination and choice-number mapping), define `self.configure_middleware_stack(builder, custom_middleware)`. The processor calls it in place of the default custom-middleware step, so you decide where your middleware and the user's sit:
91
+
92
+ ```ruby
93
+ def self.configure_middleware_stack(builder, custom_middleware)
94
+ builder.use MyCompany::Sms::Middleware::Segmentation
95
+ builder.use custom_middleware
96
+ end
97
+ ```
98
+
99
+ `custom_middleware` is the app's own custom-middleware builder. Include it, or the middleware a user added with `use_middleware` will not run.
100
+
101
+ ## Writing a choice mapper
102
+
103
+ A choice mapper turns what the platform sends back into the key the flow branches on. Get one rule right and the rest follows:
104
+
105
+ > **Decide ambiguity under the same equivalence your resolver matches on.**
106
+
107
+ Every choice bug FlowChat has had came from breaking it — the resolver normalised input one way, and nothing checked whether two choices became indistinguishable under that normalisation, so one of them silently became unreachable.
108
+
109
+ `FlowChat::ChoiceTitles` enforces the rule for you. Pass it the `fold` your resolver applies and the `measure` your platform sizes fields in, and it hands back titles that are guaranteed distinct — numbering the whole set when they otherwise would not be:
110
+
111
+ ```ruby
112
+ FlowChat::ChoiceTitles.build(choices, title_cap, measure: :characters)
113
+ # => [[key, original_label, displayed_title, was_truncated], ...]
114
+ ```
115
+
116
+ Then make the displayed title the value you put on the wire. It is already unique within the set, so it needs no separate id space to be unique in — a tap sends it back as the payload, and a user who types what they read sends the same string, so one map resolves both.
117
+
118
+ **The best fold is no fold, and today no mapper uses one:**
119
+
120
+ | Mapper | Resolves on | Measure |
121
+ |---|---|---|
122
+ | WhatsApp, Messenger, Instagram | the displayed title, matched exactly | characters |
123
+ | Telegram | the displayed title, cut to `callback_data`'s limit | **bytes** |
124
+ | HTTP | the displayed title, matched exactly | characters |
125
+ | USSD, Intercom | the position printed beside each option | — |
126
+
127
+ USSD and Intercom are the strongest form of the rule: positions are unique whatever the labels say, so their equivalence relation is already injective and there is nothing to check. If your platform prints a number and asks for one, do that and you need none of this. Intercom used to match labels case-insensitively as well, and that is exactly what made two options reading the same collapse onto one entry — the number beside them was already doing that job unambiguously.
128
+
129
+ The rest match exactly, because a tapped payload and a client-echoed string are both produced by machines rather than typed. Every transform that could absorb a drift in those strings can also merge two choices, so none is worth adding on speculation. If you do add one — a platform where a person types freely might justify case folding — pass it as `fold:` so the ambiguity check uses it too, and expect more sets to be numbered as a result.
130
+
131
+ Where the number is already on screen — Intercom's numbered list, or the Meta `:numbered` rung — the mapper does not prefix anything, because the renderer is doing it. Prefixing in both places reads as `1. 1. Savings`.
132
+
133
+ Number the set rather than disambiguating with a suffix. A position prefix sits at the front and survives truncation from the right, which is what makes it work even on a platform as tight as Telegram's 64 bytes; a suffix is the first thing a cut removes.
134
+
135
+ ## Supporting async
136
+
137
+ Include `FlowChat::GatewayAsyncSupport` to let the gateway run flows in a background job. The concern provides `should_enqueue_async?` (true when async is enabled, the gateway supports it, and the request is not already running in the background) and `enqueue_async_job` (serializes the request and enqueues the job). Override `async_supported?` to return `false` on a synchronous protocol:
138
+
139
+ ```ruby
140
+ class Twilio
141
+ include FlowChat::GatewayAsyncSupport
142
+
143
+ def call(context)
144
+ @context = context
145
+ @controller = context.controller
146
+ return if enqueue_async_job # enqueued; respond immediately
147
+
148
+ # ... otherwise process inline as above
149
+ end
150
+ end
151
+ ```
152
+
153
+ `enqueue_async_job` returns `false` when async should not be used (for example when the request is already the background job), so you fall through to inline processing. See [async-background-processing.md](async-background-processing.md).
154
+
155
+ ## Related
156
+
157
+ - [Architecture](architecture.md)
158
+ - [Gateway context variables](gateway-context-variables.md)
159
+ - [Async and background processing](async-background-processing.md)
@@ -0,0 +1,90 @@
1
+ # Getting Started
2
+
3
+ This guide takes you from an empty Rails app to a running FlowChat flow. It reuses the same flow and processor shown in the [README](../README.md).
4
+
5
+ ## Prerequisites
6
+
7
+ - A Rails application.
8
+ - Ruby 3.0 or newer.
9
+ - A cache the session store can use, such as `Rails.cache`.
10
+
11
+ ## Install
12
+
13
+ Add the gem to your Gemfile:
14
+
15
+ ```ruby
16
+ gem "flow_chat"
17
+ ```
18
+
19
+ Then run `bundle install`. There are no migrations and no generators to run.
20
+
21
+ ## Configure the cache
22
+
23
+ The cache-backed session store needs a cache. Set it once during boot, for example in an initializer:
24
+
25
+ ```ruby
26
+ # config/initializers/flow_chat.rb
27
+ FlowChat::Config.cache = Rails.cache
28
+ ```
29
+
30
+ Without a cache configured, `FlowChat::Session::CacheSessionStore` raises when it tries to read or write a session.
31
+
32
+ ## Write your first flow
33
+
34
+ A flow is a class that inherits `FlowChat::Flow` and reads and writes the conversation through `app`. Each `app.screen` is one step:
35
+
36
+ ```ruby
37
+ class RegistrationFlow < FlowChat::Flow
38
+ def main_page
39
+ name = app.screen(:name) { |prompt| prompt.ask "What's your name?" }
40
+
41
+ email = app.screen(:email) do |prompt|
42
+ prompt.ask "Your email?", validate: ->(input) { "Invalid email" unless input.include?("@") }
43
+ end
44
+
45
+ app.say "Welcome #{name}!"
46
+ end
47
+ end
48
+ ```
49
+
50
+ FlowChat re-runs this method from the top on every webhook. Each `screen` returns its stored answer when the session already holds one, and re-prompts when it does not, so the method reads as a straight-line script even though each turn is a separate stateless request. For the mechanics, see [How the replay engine works](../README.md#how-the-replay-engine-works).
51
+
52
+ Put the flow anywhere Rails autoloads it, for example `app/flow_chat/registration_flow.rb`.
53
+
54
+ ## Wire a controller
55
+
56
+ Build a processor in your webhook action, choose a gateway and session store, and run the flow. Here `self` is the controller:
57
+
58
+ ```ruby
59
+ # app/controllers/ussd_controller.rb
60
+ class UssdController < ApplicationController
61
+ skip_forgery_protection
62
+
63
+ def webhook
64
+ processor = FlowChat::Processor.new(self) do |config|
65
+ config.use_gateway FlowChat::Ussd::Gateway::Nalo
66
+ config.use_session_store FlowChat::Session::CacheSessionStore
67
+ end
68
+
69
+ processor.run RegistrationFlow, :main_page
70
+ end
71
+ end
72
+ ```
73
+
74
+ `skip_forgery_protection` is needed because gateway webhooks are POSTs from an external service that cannot carry a Rails CSRF token. Point a route at the action:
75
+
76
+ ```ruby
77
+ # config/routes.rb
78
+ post "/ussd", to: "ussd#webhook"
79
+ ```
80
+
81
+ To run the same flow on another platform, change only `config.use_gateway`. The gateway classes and their platform symbols are listed in the [README](../README.md#wiring-a-platform).
82
+
83
+ ## Try it in the simulator
84
+
85
+ FlowChat ships a web simulator for driving flows locally without a real gateway. It requires `FlowChat::Config.simulator_secret` to be set (for example in your initializer). Once set, you can step through a flow from the browser during development. See [testing.md](testing.md) for how to mount and use it.
86
+
87
+ ## Next steps
88
+
89
+ - [configuration.md](configuration.md) for the full config surface and session options.
90
+ - [platforms/ussd.md](platforms/ussd.md), [platforms/whatsapp.md](platforms/whatsapp.md), and [platforms/telegram.md](platforms/telegram.md) for platform-specific behavior.