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