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,205 @@
|
|
|
1
|
+
# Messenger
|
|
2
|
+
|
|
3
|
+
The `FlowChat::Messenger::Gateway::SendApi` gateway integrates Facebook Messenger through Meta's Messenger Platform: the Send API for outbound messages and the `entry[].messaging[]` webhook for inbound ones. It handles webhook verification and signature checks, parses text, quick-reply taps, postbacks and attachments, and renders your flow's output as quick replies, a carousel, or plain numbered text depending on how many choices a screen offers.
|
|
4
|
+
|
|
5
|
+
Messenger and Instagram share this webhook envelope and most of their rendering logic (`FlowChat::Meta::MessagingGateway`), but each has its own configuration, client and limits, and a message meant for one is not deliverable through the other's credentials.
|
|
6
|
+
|
|
7
|
+
## Credentials
|
|
8
|
+
|
|
9
|
+
The gateway needs a Page access token, the Page id, and a verify token; an app secret is needed to validate webhook signatures.
|
|
10
|
+
|
|
11
|
+
```yaml
|
|
12
|
+
# config/credentials.yml.enc
|
|
13
|
+
messenger:
|
|
14
|
+
access_token: "..."
|
|
15
|
+
page_id: "..."
|
|
16
|
+
verify_token: "..." # your own value, echoed back during webhook setup
|
|
17
|
+
app_id: "..." # used to classify echoes as :self, see below
|
|
18
|
+
app_secret: "..." # used to verify X-Hub-Signature-256
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Equivalent environment variables: `MESSENGER_ACCESS_TOKEN`, `MESSENGER_PAGE_ID`, `MESSENGER_VERIFY_TOKEN`, `MESSENGER_APP_ID`, `MESSENGER_APP_SECRET`.
|
|
22
|
+
|
|
23
|
+
## Setup
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
# app/controllers/messenger_controller.rb
|
|
27
|
+
class MessengerController < ApplicationController
|
|
28
|
+
skip_forgery_protection
|
|
29
|
+
|
|
30
|
+
def webhook
|
|
31
|
+
processor = FlowChat::Processor.new(self) do |config|
|
|
32
|
+
config.use_gateway FlowChat::Messenger::Gateway::SendApi
|
|
33
|
+
config.use_session_store FlowChat::Session::CacheSessionStore
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
processor.run RegistrationFlow, :main_page
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
# config/routes.rb
|
|
43
|
+
match "/messenger/webhook", to: "messenger#webhook", via: [:get, :post]
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Both verbs are needed: Meta sends a `GET` with `hub.mode=subscribe` to verify the endpoint (the gateway answers it using your `verify_token`), and `POST`s the actual events. Each `POST` is checked against `X-Hub-Signature-256` using the app secret; a request with a bad signature is answered `200 OK` without processing, so Meta stops retrying it.
|
|
47
|
+
|
|
48
|
+
With no second argument, `use_gateway` loads credentials through `FlowChat::Messenger::Configuration.from_credentials`, which reads the Rails credentials or environment variables above. That is the setup shown here.
|
|
49
|
+
|
|
50
|
+
### Webhook fields
|
|
51
|
+
|
|
52
|
+
Meta's Messenger webhook offers many subscribable fields; the gateway only models a few of them and publishes the rest as raw `WEBHOOK_RECEIVED` events (field name and value, unmodelled) rather than dropping them. Subscribe at least:
|
|
53
|
+
|
|
54
|
+
- `messages`: text, quick-reply taps, attachments, and message echoes.
|
|
55
|
+
- `messaging_postbacks`: carousel button taps, Get Started, ice breakers.
|
|
56
|
+
|
|
57
|
+
Optionally, `message_deliveries` and `message_reads` surface as `MESSAGE_STATUS` events. Anything else you subscribe to (`messaging_referrals`, `messaging_optins`, and so on) arrives through `WEBHOOK_RECEIVED` for your own code to interpret.
|
|
58
|
+
|
|
59
|
+
## Explicit and multi-page configuration
|
|
60
|
+
|
|
61
|
+
To run more than one Page, or to load credentials from somewhere other than Rails credentials, build a `FlowChat::Messenger::Configuration` and pass it as the second argument to `use_gateway`.
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
config = FlowChat::Messenger::Configuration.new(:support).tap do |c|
|
|
65
|
+
c.access_token = tenant.messenger_access_token
|
|
66
|
+
c.page_id = tenant.messenger_page_id
|
|
67
|
+
c.verify_token = tenant.messenger_verify_token
|
|
68
|
+
c.app_id = tenant.messenger_app_id
|
|
69
|
+
c.app_secret = tenant.messenger_app_secret
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
processor = FlowChat::Processor.new(self) do |cfg|
|
|
73
|
+
cfg.use_gateway FlowChat::Messenger::Gateway::SendApi, config
|
|
74
|
+
cfg.use_session_store FlowChat::Session::CacheSessionStore
|
|
75
|
+
end
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Passing a name to `new` registers the configuration under that name, so you can retrieve it later with `FlowChat::Messenger::Configuration.get(:support)`. For an unnamed configuration, use `FlowChat::Messenger::Configuration.new(nil)`. The configuration attributes are `access_token`, `page_id`, `verify_token`, `app_id`, `app_secret`, and `skip_signature_validation` (set it to `true` to bypass the `X-Hub-Signature-256` check, for local testing only).
|
|
79
|
+
|
|
80
|
+
## The flow is the same
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
class RegistrationFlow < FlowChat::Flow
|
|
84
|
+
def main_page
|
|
85
|
+
name = app.screen(:name) { |prompt| prompt.ask "What's your name?" }
|
|
86
|
+
|
|
87
|
+
plan = app.screen(:plan) do |prompt|
|
|
88
|
+
prompt.select "Choose a plan", { "basic" => "Basic", "pro" => "Pro" }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
app.say "Welcome #{name}!"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`app.msisdn` is always `nil` on Messenger; there is no phone number in the PSID Meta assigns a user. Use `app.user_id` (the PSID) as the stable per-user identifier, which is also what sessions key on by default.
|
|
97
|
+
|
|
98
|
+
## How choices render
|
|
99
|
+
|
|
100
|
+
The renderer picks a rung by choice count:
|
|
101
|
+
|
|
102
|
+
| Choices | Rendered as |
|
|
103
|
+
|---|---|
|
|
104
|
+
| 0 | Plain text, split into multiple messages above 2000 characters |
|
|
105
|
+
| 1 to 13 | Quick replies, one per choice |
|
|
106
|
+
| 14 to 30 | A carousel: choices packed as postback buttons across generic-template cards, 3 buttons per card |
|
|
107
|
+
| 31 or more | Plain text with the options numbered, `1.`, `2.`, and so on |
|
|
108
|
+
|
|
109
|
+
13 is Meta's cap on quick replies per message; 30 is 10 carousel elements times 3 buttons per element, Meta's caps on the generic template. A carousel card's own title is not one of your choice labels: each card is titled "Options 1 to 3", "Options 4 to 6", and so on, describing which of the packed buttons it holds, because one option is one button rather than one option per card.
|
|
110
|
+
|
|
111
|
+
Quick-reply and carousel-button titles are only numbered when they need to be. FlowChat truncates each title to fit (20 characters) and checks the whole set: if any title had to be truncated, or if two choices land on the same title, the titles as displayed can no longer identify a choice on their own. When that happens, every title in the set gets prefixed with its 1-based position ("1. ", "2. ", and so on), not just the ones that collided - a stray "2." next to a title with no "1." beside it would be worse than no numbering at all. This is decided across the whole choice set, not per carousel card: two cards could each hold an option labelled "Foo" that only collides once the full set is in view, and the numbering carries on across cards regardless (button 14 reads "14.", continuing from the element before it, not restarting at "2." within its own card). A short menu of distinct options (`Yes` / `No`) stays unprefixed; a menu with a long label, or with two choices sharing a label, gets every title numbered.
|
|
112
|
+
|
|
113
|
+
A user can reply to any screen with choices by tapping, by typing the title exactly as shown, or - only when the screen was numbered - by typing the position number. All of these resolve to the same choice. Tapping sends back the payload FlowChat generated for the button; typing the title sends back that exact string; typing the number sends back its position. These are tracked as three separate mappings, resolved in that order (payload, then title, then position), so a choice labelled `"1"` (whose generated payload is also `"1"`) cannot be confused with the first position. Above the carousel cap there is nothing tappable and no separate title, only the number printed in the message body, always shown, so a typed number is the only way to reply.
|
|
114
|
+
|
|
115
|
+
## Media
|
|
116
|
+
|
|
117
|
+
Read inbound media through `app.media`, an Array of `FlowChat::Media`. Meta puts a direct, signed CDN URL on the attachment, so there is no separate media-id lookup step the way WhatsApp needs one:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
photo = app.media.first
|
|
121
|
+
if photo
|
|
122
|
+
photo.type # => :image
|
|
123
|
+
bytes = photo.download
|
|
124
|
+
end
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Signed CDN URLs expire; fetch `download` during the turn it arrives rather than from a session-stored answer later.
|
|
128
|
+
|
|
129
|
+
Send media outbound by passing `media:` to `ask` or `say`:
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
app.say "Here is the map", media: { type: :image, url: "https://example.com/map.png" }
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`upload_media` uploads a file for reuse and returns an attachment id you can pass as `media: { type: :image, id: attachment_id }` on a later send, avoiding a re-upload.
|
|
136
|
+
|
|
137
|
+
Media and choices combine: pass both `media:` and `choices:` to `ask` or `say` and you get both, not one or the other. Media does not change which choice surface renders - it is additive, sent as its own message ahead of whichever quick replies, carousel, or numbered text the choice count would render with no media at all:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
app.screen(:plan) do |prompt|
|
|
141
|
+
prompt.select "Choose a plan", { "basic" => "Basic", "pro" => "Pro" },
|
|
142
|
+
media: { type: :image, url: "https://example.com/plans.png" }
|
|
143
|
+
end
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
A user sees the image, then the question with its quick replies (or carousel, or numbered list) right after.
|
|
147
|
+
|
|
148
|
+
## Echoes and coexistence
|
|
149
|
+
|
|
150
|
+
Messenger reports every message sent on a thread, including one typed by a human in Page Inbox and one sent by a different app connected to the same Page, as a `message_echoes` event. FlowChat never lets an echo drive a flow (an echo of the bot's own send driving the flow again would loop), but it is published through the usual `WEBHOOK_RECEIVED` event, `field: "message_echoes"`, with a derived `echo_origin`:
|
|
151
|
+
|
|
152
|
+
| `echo_origin` | Meaning |
|
|
153
|
+
|---|---|
|
|
154
|
+
| `:self` | The echo's `app_id` matches this configuration's `app_id`. Our own send coming back. |
|
|
155
|
+
| `:other_app` | An `app_id` is present but does not match. Another connected app sent it. |
|
|
156
|
+
| `:human_agent` | No `app_id` at all. A person replying from Page Inbox. |
|
|
157
|
+
|
|
158
|
+
`:human_agent` is usually the signal an application wants: stand the flow down while a person is handling the conversation, and let it resume (or not) on your own logic.
|
|
159
|
+
|
|
160
|
+
## Who can be on each side
|
|
161
|
+
|
|
162
|
+
The account running the flow must be a Facebook **Page**, linked to the app the credentials belong to. Messenger has no person-to-person mode: an app cannot send as somebody's personal Facebook profile, and Meta has not allowed that for years. If you are looking for a way to automate replies from your own profile to your own friends, this gateway is not it, and neither is any Meta API.
|
|
163
|
+
|
|
164
|
+
The person on the other side is an ordinary Facebook user messaging the Page, which is the normal case and needs nothing from them.
|
|
165
|
+
|
|
166
|
+
Group threads are not supported. The webhook envelope pairs one sender with one recipient, and Meta does not expose group threads through this API.
|
|
167
|
+
|
|
168
|
+
Unlike Instagram, a Messenger conversation does not have to begin with the user: a Page may open one with an approved message tag, subject to the window below. FlowChat does not model tags, so a flow that needs to speak first has to send through `context["messenger.client"]` with whatever the tag rules require.
|
|
169
|
+
|
|
170
|
+
## The 24-hour window
|
|
171
|
+
|
|
172
|
+
Meta restricts free-form Messenger sends to within 24 hours of the user's last message, or to conversations opened with an approved message tag. FlowChat does not track this window automatically, but it does carry a tag when you ask it to. Pass `tag:` to `context["messenger.client"]`'s `send_message` or `send_text`:
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
context["messenger.client"].send_message(psid, "Following up on your case", tag: "HUMAN_AGENT")
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`HUMAN_AGENT` is the only tag Meta still accepts as of 27 April 2026, and it extends the window to 7 days for human-agent support; it needs the Human Agent app feature approved on your app first. FlowChat passes whatever you give it straight through to the Send API without checking it against a list, since Meta already refuses an unknown tag clearly (error 100) and an allowlist here would be one more thing to keep in sync with Meta's own set. Deciding when a send qualifies for the tag is the application's job.
|
|
179
|
+
|
|
180
|
+
The tag replaces `messaging_type: "RESPONSE"` rather than riding alongside it; a tagged send never carries both. When a reply is long enough to split, or carries media alongside choices and so goes out as more than one message, every part carries the same tag - a customer should never end up with the first half of a reply going through on the tag and the second half rejected because it didn't.
|
|
181
|
+
|
|
182
|
+
A send outside the window with no tag is attempted like any other send: the Send API rejects it, the rejection is logged and reported through the standard API-error instrumentation, and the flow's turn otherwise proceeds as if the send had gone out. There is no retry and no automatic fallback to a template; both are the application's responsibility.
|
|
183
|
+
|
|
184
|
+
## Limits
|
|
185
|
+
|
|
186
|
+
| Area | Behavior on Messenger |
|
|
187
|
+
|---|---|
|
|
188
|
+
| Text length | 2000 characters, split across multiple messages above that |
|
|
189
|
+
| Quick replies | 13 per message, title truncated to 20 characters |
|
|
190
|
+
| Carousel | 10 elements, 3 postback buttons per element, button title truncated to 20 characters |
|
|
191
|
+
| Choice payload | Generated ids are capped at 1000 characters |
|
|
192
|
+
| Attachments | One per inbound message is read (the first); outbound is one attachment per send |
|
|
193
|
+
| Media with choices | Sent as its own message ahead of the choice message; does not change which rung renders |
|
|
194
|
+
| 24-hour window | Not tracked automatically; `tag:` is passed through unvalidated, see above |
|
|
195
|
+
|
|
196
|
+
## Async
|
|
197
|
+
|
|
198
|
+
Messenger supports background processing with `use_async`. See [factory-pattern.md](../factory-pattern.md) and [async-background-processing.md](../async-background-processing.md).
|
|
199
|
+
|
|
200
|
+
## Related
|
|
201
|
+
|
|
202
|
+
- [Instagram](instagram.md)
|
|
203
|
+
- [Getting started](../getting-started.md)
|
|
204
|
+
- [Configuration](../configuration.md)
|
|
205
|
+
- [Instrumentation](../instrumentation.md)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Telegram
|
|
2
|
+
|
|
3
|
+
The `FlowChat::Telegram::Gateway::BotApi` gateway integrates the Telegram Bot API. It parses inbound messages and callback queries, renders choices as inline keyboards, formats text as Telegram HTML, and sends media through the Bot API.
|
|
4
|
+
|
|
5
|
+
## Credentials
|
|
6
|
+
|
|
7
|
+
The gateway needs a bot token from [@BotFather](https://t.me/BotFather). A secret token is optional but recommended: Telegram sends it back in the `X-Telegram-Bot-Api-Secret-Token` header on every webhook, and the gateway rejects requests whose token does not match.
|
|
8
|
+
|
|
9
|
+
```yaml
|
|
10
|
+
# config/credentials.yml.enc
|
|
11
|
+
telegram:
|
|
12
|
+
bot_token: "123456:ABC-DEF..."
|
|
13
|
+
secret_token: "..." # optional; validates incoming webhooks
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Equivalent environment variables: `TELEGRAM_BOT_TOKEN`, `TELEGRAM_SECRET_TOKEN`.
|
|
17
|
+
|
|
18
|
+
## Setup
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
# app/controllers/telegram_controller.rb
|
|
22
|
+
class TelegramController < ApplicationController
|
|
23
|
+
skip_forgery_protection
|
|
24
|
+
|
|
25
|
+
def webhook
|
|
26
|
+
processor = FlowChat::Processor.new(self) do |config|
|
|
27
|
+
config.use_gateway FlowChat::Telegram::Gateway::BotApi
|
|
28
|
+
config.use_session_store FlowChat::Session::CacheSessionStore
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
processor.run RegistrationFlow, :main_page
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
# config/routes.rb
|
|
38
|
+
post "/telegram/webhook", to: "telegram#webhook"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Telegram delivers updates by `POST`. Register your webhook URL with the Bot API's `setWebhook` method, passing the same `secret_token` you configured so Telegram includes it on each request.
|
|
42
|
+
|
|
43
|
+
With no second argument, `use_gateway` loads credentials through `FlowChat::Telegram::Configuration.from_credentials`, which reads the Rails credentials or environment variables above. That is the setup shown here.
|
|
44
|
+
|
|
45
|
+
## Explicit and multi-bot configuration
|
|
46
|
+
|
|
47
|
+
To run more than one bot, or to load the token from somewhere other than Rails credentials, build a `FlowChat::Telegram::Configuration` and pass it as the second argument to `use_gateway`.
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
config = FlowChat::Telegram::Configuration.new(:support).tap do |c|
|
|
51
|
+
c.bot_token = tenant.telegram_bot_token
|
|
52
|
+
c.secret_token = tenant.telegram_secret_token
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
processor = FlowChat::Processor.new(self) do |cfg|
|
|
56
|
+
cfg.use_gateway FlowChat::Telegram::Gateway::BotApi, config
|
|
57
|
+
cfg.use_session_store FlowChat::Session::CacheSessionStore
|
|
58
|
+
end
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Passing a name to `new` registers the configuration under that name, so you can retrieve it later with `FlowChat::Telegram::Configuration.get(:support)`. For an unnamed configuration, use `FlowChat::Telegram::Configuration.new(nil)`. The configuration attributes are `bot_token`, `secret_token`, and `skip_signature_validation` (set it to `true` to bypass the secret-token check, for local testing only).
|
|
62
|
+
|
|
63
|
+
## The flow is the same
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
class RegistrationFlow < FlowChat::Flow
|
|
67
|
+
def main_page
|
|
68
|
+
name = app.screen(:name) { |prompt| prompt.ask "What's your name?" }
|
|
69
|
+
|
|
70
|
+
email = app.screen(:email) do |prompt|
|
|
71
|
+
prompt.ask "Your email?", validate: ->(input) { "Invalid email" unless input.include?("@") }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
app.say "Welcome #{name}!"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Callback queries and inline keyboards
|
|
80
|
+
|
|
81
|
+
Choices render as an inline keyboard: buttons attached under the message. When the user taps one, Telegram sends a `callback_query` rather than a text message. The gateway uses the button's data as the input and answers the query automatically to clear the loading spinner, so your `select` returns the choice key exactly as on other platforms.
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
choice = app.screen(:menu) do |prompt|
|
|
85
|
+
prompt.select "Main menu:", { "balance" => "Check balance", "airtime" => "Buy airtime" }
|
|
86
|
+
end
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Buttons are laid out two per row for up to four choices, and one per row beyond that. Button text is truncated to 64 characters, the Bot API's limit.
|
|
90
|
+
|
|
91
|
+
## Formatting and media
|
|
92
|
+
|
|
93
|
+
Message text is rendered as Telegram HTML, so Markdown in your prompts becomes bold, italic, links, and code. Read inbound media through `app.media` and send media outbound with `media:`, the same as other platforms:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
app.say "Here is the map", media: { type: :photo, url: "https://example.com/map.png" }
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Telegram media types include photo, document, video, audio, and voice; FlowChat normalizes `:photo` to `:image` and `:voice` to `:audio` on inbound `Media` so `media.type` is consistent across platforms.
|
|
100
|
+
|
|
101
|
+
## Async
|
|
102
|
+
|
|
103
|
+
Telegram supports background processing with `use_async`. See [factory-pattern.md](../factory-pattern.md) and [async-background-processing.md](../async-background-processing.md).
|
|
104
|
+
|
|
105
|
+
## Related
|
|
106
|
+
|
|
107
|
+
- [Getting started](../getting-started.md)
|
|
108
|
+
- [Configuration](../configuration.md)
|
|
109
|
+
- [Instrumentation](../instrumentation.md)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# USSD
|
|
2
|
+
|
|
3
|
+
USSD is the synchronous, text-only channel behind codes like `*123#`. The telco opens a session, sends each user entry to your webhook as a plain POST, and shows whatever text you return. FlowChat drives it with the same flows you write for every other platform, plus USSD-specific pagination and choice numbering.
|
|
4
|
+
|
|
5
|
+
FlowChat ships one USSD gateway: `FlowChat::Ussd::Gateway::Nalo`, for the [Nalo](https://nalosolutions.com) aggregator. Other aggregators are supported by writing a gateway (see [gateway-development.md](../gateway-development.md)).
|
|
6
|
+
|
|
7
|
+
## Setup
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# app/controllers/ussd_controller.rb
|
|
11
|
+
class UssdController < ApplicationController
|
|
12
|
+
skip_forgery_protection
|
|
13
|
+
|
|
14
|
+
def webhook
|
|
15
|
+
processor = FlowChat::Processor.new(self) do |config|
|
|
16
|
+
config.use_gateway FlowChat::Ussd::Gateway::Nalo
|
|
17
|
+
config.use_session_store FlowChat::Session::CacheSessionStore
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
processor.run RegistrationFlow, :main_page
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
# config/routes.rb
|
|
27
|
+
post "/ussd", to: "ussd#webhook"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The Nalo gateway reads `USERID`, `MSISDN`, and `USERDATA` from the request and renders a JSON response with `MSG` (the text to show) and `MSGTYPE` (`true` while the session continues, `false` when it ends). The phone number is normalized to E.164 and exposed as `app.msisdn`. Nalo takes no configuration object; the aggregator identifies your service by the endpoint URL, so there are no per-gateway credentials to pass, unlike the WhatsApp and Telegram gateways.
|
|
31
|
+
|
|
32
|
+
## The flow is the same
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
class RegistrationFlow < FlowChat::Flow
|
|
36
|
+
def main_page
|
|
37
|
+
name = app.screen(:name) { |prompt| prompt.ask "What's your name?" }
|
|
38
|
+
|
|
39
|
+
email = app.screen(:email) do |prompt|
|
|
40
|
+
prompt.ask "Your email?", validate: ->(input) { "Invalid email" unless input.include?("@") }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
app.say "Welcome #{name}!"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Choice numbering
|
|
49
|
+
|
|
50
|
+
You define choices by their keys; FlowChat shows the user a numbered list and maps the number they type back to your key before the flow sees it. Your flow always works in keys, never in the displayed numbers.
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
choice = app.screen(:menu) do |prompt|
|
|
54
|
+
prompt.select "Main menu:", { "balance" => "Check balance", "airtime" => "Buy airtime" }
|
|
55
|
+
end
|
|
56
|
+
# The user sees "1. Check balance / 2. Buy airtime" and types 1 or 2.
|
|
57
|
+
# choice is "balance" or "airtime".
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Pagination
|
|
61
|
+
|
|
62
|
+
USSD messages are length-limited. When rendered output exceeds `FlowChat::Config.ussd.pagination_page_size` (140 characters by default), FlowChat splits it into pages at a word boundary and appends navigation options: `#` for "More" and `0` for "Back". It holds the paging state in the session and serves the next or previous page when the user sends the matching option, so a long menu or message spans several turns without any work in your flow. Tune the size and the option labels through [configuration.md](../configuration.md#ussd-configuration).
|
|
63
|
+
|
|
64
|
+
## Limits to keep in mind
|
|
65
|
+
|
|
66
|
+
| Area | Behavior on USSD |
|
|
67
|
+
|---|---|
|
|
68
|
+
| Message length | Output over 140 characters (default) is paginated into multiple turns. |
|
|
69
|
+
| Media | There is no inline media. Outbound `media:` is degraded to a text line with the media's URL, for example `Image: https://...`. |
|
|
70
|
+
| Rich choices | Choices render as a numbered text list, not buttons. |
|
|
71
|
+
| Async | Not supported. The USSD protocol needs a synchronous response, so `use_async` has no effect here. |
|
|
72
|
+
| Sessions | The telco's session id can rotate on timeout. Use `use_durable_sessions` to key the session on the phone number so a conversation survives a rotation. See [configuration.md](../configuration.md#sessions). |
|
|
73
|
+
|
|
74
|
+
## Related
|
|
75
|
+
|
|
76
|
+
- [Getting started](../getting-started.md)
|
|
77
|
+
- [Configuration](../configuration.md)
|
|
78
|
+
- [Building a gateway](../gateway-development.md)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# WhatsApp
|
|
2
|
+
|
|
3
|
+
The `FlowChat::Whatsapp::Gateway::CloudApi` gateway integrates the WhatsApp Business Cloud API. It handles Meta's webhook verification and signature checks, parses inbound messages (text, interactive replies, media, location, contacts), and renders your flow's output as WhatsApp interactive messages.
|
|
4
|
+
|
|
5
|
+
## Credentials
|
|
6
|
+
|
|
7
|
+
The gateway needs an access token, a phone number id, and a verify token; an app secret is needed to validate webhook signatures. Provide them through Rails credentials, environment variables, or a configuration object.
|
|
8
|
+
|
|
9
|
+
```yaml
|
|
10
|
+
# config/credentials.yml.enc
|
|
11
|
+
whatsapp:
|
|
12
|
+
access_token: "..."
|
|
13
|
+
phone_number_id: "..."
|
|
14
|
+
verify_token: "..." # your own value, echoed back during webhook setup
|
|
15
|
+
app_secret: "..." # used to verify X-Hub-Signature-256
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Equivalent environment variables: `WHATSAPP_ACCESS_TOKEN`, `WHATSAPP_PHONE_NUMBER_ID`, `WHATSAPP_VERIFY_TOKEN`, `WHATSAPP_APP_SECRET`, `WHATSAPP_BUSINESS_ACCOUNT_ID`.
|
|
19
|
+
|
|
20
|
+
## Setup
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
# app/controllers/whatsapp_controller.rb
|
|
24
|
+
class WhatsappController < ApplicationController
|
|
25
|
+
skip_forgery_protection
|
|
26
|
+
|
|
27
|
+
def webhook
|
|
28
|
+
processor = FlowChat::Processor.new(self) do |config|
|
|
29
|
+
config.use_gateway FlowChat::Whatsapp::Gateway::CloudApi
|
|
30
|
+
config.use_session_store FlowChat::Session::CacheSessionStore
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
processor.run RegistrationFlow, :main_page
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
# config/routes.rb
|
|
40
|
+
match "/whatsapp/webhook", to: "whatsapp#webhook", via: [:get, :post]
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Both verbs are needed: Meta sends a `GET` with `hub.mode=subscribe` to verify the endpoint (the gateway answers it using your `verify_token`), and `POST`s the actual messages. Each `POST` is checked against `X-Hub-Signature-256` using the app secret; a request with a bad signature is answered `200 OK` without processing, so Meta stops retrying it.
|
|
44
|
+
|
|
45
|
+
With no second argument, `use_gateway` loads credentials through `FlowChat::Whatsapp::Configuration.from_credentials`, which reads the Rails credentials or environment variables above. That is the setup shown here.
|
|
46
|
+
|
|
47
|
+
## Explicit and multi-tenant configuration
|
|
48
|
+
|
|
49
|
+
To run more than one WhatsApp number, or to load credentials from somewhere other than Rails credentials, build a `FlowChat::Whatsapp::Configuration` and pass it as the second argument to `use_gateway`.
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
config = FlowChat::Whatsapp::Configuration.new(:support).tap do |c|
|
|
53
|
+
c.access_token = tenant.whatsapp_access_token
|
|
54
|
+
c.phone_number_id = tenant.whatsapp_phone_number_id
|
|
55
|
+
c.verify_token = tenant.whatsapp_verify_token
|
|
56
|
+
c.app_secret = tenant.whatsapp_app_secret
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
processor = FlowChat::Processor.new(self) do |cfg|
|
|
60
|
+
cfg.use_gateway FlowChat::Whatsapp::Gateway::CloudApi, config
|
|
61
|
+
cfg.use_session_store FlowChat::Session::CacheSessionStore
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Passing a name to `new` registers the configuration under that name, so you can retrieve it later with `FlowChat::Whatsapp::Configuration.get(:support)`. For an unnamed configuration, use `FlowChat::Whatsapp::Configuration.new(nil)`. The configuration attributes are `access_token`, `phone_number_id`, `verify_token`, `app_secret`, `business_account_id`, and `skip_signature_validation` (set it to `true` to bypass the `X-Hub-Signature-256` check, for local testing only).
|
|
66
|
+
|
|
67
|
+
## The flow is the same
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
class RegistrationFlow < FlowChat::Flow
|
|
71
|
+
def main_page
|
|
72
|
+
name = app.screen(:name) { |prompt| prompt.ask "What's your name?" }
|
|
73
|
+
|
|
74
|
+
email = app.screen(:email) do |prompt|
|
|
75
|
+
prompt.ask "Your email?", validate: ->(input) { "Invalid email" unless input.include?("@") }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
app.say "Welcome #{name}!"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## How choices render
|
|
84
|
+
|
|
85
|
+
FlowChat picks the WhatsApp interactive type from the number of choices:
|
|
86
|
+
|
|
87
|
+
- 3 choices or fewer render as reply buttons.
|
|
88
|
+
- 4 to 10 render as a list.
|
|
89
|
+
- Above 10 there is no interactive surface left: the options go straight into the message body, one per line, numbered.
|
|
90
|
+
|
|
91
|
+
Button and list row titles are only numbered when they need to be. FlowChat truncates each title to fit (20 characters for a button, 24 for a list row) and checks the whole set: if any title had to be truncated, or if two choices land on the same title, the titles as displayed can no longer identify a choice on their own. When that happens, every title in the set gets prefixed with its 1-based position ("1. ", "2. ", and so on), not just the ones that collided, so a stray "2." never appears next to a title with no "1." beside it. A short menu of distinct options (`Yes` / `No`) stays unprefixed; a menu with a long label, or with two choices sharing a label (two accounts both named "Savings"), gets every title numbered (`1. Yes` / `2. No`, `1. Transfer to savin...` / `2. Transfer to salar...`).
|
|
92
|
+
|
|
93
|
+
A user can reply by tapping, by typing the title exactly as shown, or - only when the screen was numbered - by typing the number. All of these resolve to the same choice key, so your flow reads `select` results the same way regardless of which one the user did. Above 10 choices, where nothing is tappable, the options go straight into the message body, one per line, always numbered, and a typed number is the only way to reply.
|
|
94
|
+
|
|
95
|
+
## Media
|
|
96
|
+
|
|
97
|
+
Read inbound media through `app.media`, an Array of `FlowChat::Media`:
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
photo = app.media.first
|
|
101
|
+
if photo
|
|
102
|
+
photo.type # => :image
|
|
103
|
+
photo.mime_type # => "image/jpeg"
|
|
104
|
+
bytes = photo.download
|
|
105
|
+
end
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Send media outbound by passing `media:` to `ask` or `say`:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
app.say "Your receipt", media: { type: :document, url: "https://example.com/receipt.pdf" }
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The WhatsApp client also exposes direct senders (`send_image`, `send_document`, `send_audio`, `send_video`, `send_sticker`, `send_template`) and `upload_media`, which uploads a file and returns a media id you can reuse.
|
|
115
|
+
|
|
116
|
+
Media and choices combine, but media never changes which choice surface renders. 3 choices or fewer is the one case WhatsApp can carry both in a single message: the media becomes the header on the reply buttons.
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
app.screen(:plan) do |prompt|
|
|
120
|
+
prompt.select "Choose a plan", { "basic" => "Basic", "pro" => "Pro" },
|
|
121
|
+
media: { type: :image, url: "https://example.com/plans.png" }
|
|
122
|
+
end
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
From 4 to 10 choices there is no interactive surface left that can carry media: Meta's interactive message reference documents a `text`-only header for list messages, image and video and document headers are only defined for button messages. So the image goes out as its own message first, with no caption (the question is about to appear in the list body right behind it), followed by the list exactly as it would render with no media at all.
|
|
126
|
+
|
|
127
|
+
Above 10 choices the options are already nothing but text, and a WhatsApp media message can carry a caption up to 1024 characters (documented for image, video, and document messages; audio and sticker messages have no caption field at all). When the media type supports a caption and the prompt plus the numbered options fit under that cap, FlowChat sends one message: the media with the whole numbered list as its caption. When either does not hold - a long option list, or an audio or sticker attachment - it falls back to the same two-message shape as the list rung: the media on its own, with no caption, followed by the numbered text.
|
|
128
|
+
|
|
129
|
+
## Limits to keep in mind
|
|
130
|
+
|
|
131
|
+
| Area | Behavior on WhatsApp |
|
|
132
|
+
|---|---|
|
|
133
|
+
| Button titles | Reply-button titles are truncated to 20 characters. |
|
|
134
|
+
| List titles | List row titles are truncated to 24 characters; the full text is moved into the row description (up to 72 characters). |
|
|
135
|
+
| List size | A list holds at most 10 rows in a single section; above that there is no interactive surface left, and the options go into the numbered message body instead. |
|
|
136
|
+
| Media with choices | 3 or fewer: one message, buttons with a media header. 4 to 10: media sent separately, then the list. Above 10: one captioned media message when the caption fits under 1024 characters and the media type supports a caption (image, video, document), otherwise media sent separately, then the numbered text. Never more than 3 reply buttons, with or without media. |
|
|
137
|
+
| 24-hour window | WhatsApp only allows free-form messages within 24 hours of the user's last message. Outside that window you must send an approved template. FlowChat does not abstract this: `send_template` exists, but you manage templates and the window yourself. |
|
|
138
|
+
|
|
139
|
+
## Async
|
|
140
|
+
|
|
141
|
+
WhatsApp supports background processing. Acknowledge the webhook immediately and run the flow in a job with `use_async`. See [factory-pattern.md](../factory-pattern.md) and [async-background-processing.md](../async-background-processing.md).
|
|
142
|
+
|
|
143
|
+
## Related
|
|
144
|
+
|
|
145
|
+
- [Getting started](../getting-started.md)
|
|
146
|
+
- [Configuration](../configuration.md)
|
|
147
|
+
- [Instrumentation](../instrumentation.md)
|