flow_chat 0.9.0 → 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/.github/workflows/pages.yml +43 -0
- data/CHANGELOG.md +26 -0
- data/README.md +166 -295
- data/Rakefile +12 -1
- data/SECURITY.md +1 -1
- data/docs/architecture.md +52 -479
- data/docs/async-background-processing.md +31 -265
- data/docs/configuration.md +106 -613
- data/docs/factory-pattern.md +27 -324
- data/docs/gateway-context-variables.md +140 -143
- data/docs/gateway-development.md +86 -650
- data/docs/getting-started.md +40 -379
- data/docs/instrumentation.md +88 -279
- data/docs/platforms/instagram.md +278 -0
- data/docs/platforms/messenger.md +205 -0
- data/docs/platforms/telegram.md +47 -951
- data/docs/platforms/ussd.md +38 -653
- data/docs/platforms/whatsapp.md +73 -1321
- 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 +26 -297
- data/examples/http_controller.rb +12 -10
- data/examples/intercom_configuration_example.rb +19 -24
- data/examples/intercom_controller.rb +8 -20
- data/examples/multi_tenant_whatsapp_controller.rb +61 -166
- data/examples/simulator_controller.rb +0 -1
- data/examples/ussd_controller.rb +86 -158
- data/examples/whatsapp_controller.rb +16 -15
- data/examples/whatsapp_media_examples.rb +27 -79
- data/lib/flow_chat/app.rb +129 -11
- data/lib/flow_chat/choice_titles.rb +95 -0
- data/lib/flow_chat/config.rb +124 -1
- data/lib/flow_chat/delivery_error.rb +9 -0
- data/lib/flow_chat/http/configuration_error.rb +9 -0
- data/lib/flow_chat/http/gateway/simple.rb +24 -4
- data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
- 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.rb +160 -1
- data/lib/flow_chat/intercom/client.rb +34 -28
- data/lib/flow_chat/intercom/configuration.rb +2 -49
- data/lib/flow_chat/intercom/configuration_error.rb +9 -0
- data/lib/flow_chat/intercom/gateway/intercom_api.rb +81 -57
- data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
- data/lib/flow_chat/intercom/renderer.rb +57 -5
- 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/prompt.rb +13 -16
- data/lib/flow_chat/renderers/markdown_support.rb +109 -0
- data/lib/flow_chat/security.rb +76 -0
- data/lib/flow_chat/session/middleware.rb +11 -2
- 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 +47 -4
- data/lib/flow_chat/telegram/configuration.rb +2 -42
- data/lib/flow_chat/telegram/configuration_error.rb +9 -0
- data/lib/flow_chat/telegram/gateway/bot_api.rb +46 -28
- data/lib/flow_chat/telegram/middleware/choice_mapper.rb +77 -16
- data/lib/flow_chat/telegram/renderer.rb +10 -2
- data/lib/flow_chat/text_truncator.rb +75 -0
- data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
- data/lib/flow_chat/version.rb +1 -1
- data/lib/flow_chat/whatsapp/client.rb +14 -7
- data/lib/flow_chat/whatsapp/configuration.rb +12 -51
- data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
- data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +254 -203
- data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +144 -57
- data/lib/flow_chat/whatsapp/renderer.rb +121 -60
- data/lib/flow_chat.rb +0 -10
- 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 +51 -3
- data/lib/flow_chat/whatsapp/id_generator.rb +0 -124
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# Instagram
|
|
2
|
+
|
|
3
|
+
The `FlowChat::Instagram::Gateway::SendApi` gateway integrates Instagram Direct Messages through the same Messenger Platform infrastructure Facebook Messenger uses: the Send API for outbound messages and the `entry[].messaging[]` webhook for inbound ones.
|
|
4
|
+
|
|
5
|
+
Meta offers two ways to reach Instagram messaging, and FlowChat implements both as one gateway with a configuration switch, not two gateways.
|
|
6
|
+
|
|
7
|
+
| | Instagram API with Facebook Login | Instagram API with Instagram Login |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| Linked Facebook Page | Required | Not required |
|
|
10
|
+
| Login flow | Facebook Login for Business | Business Login for Instagram |
|
|
11
|
+
| Access token | Facebook User or Page token | Instagram User token |
|
|
12
|
+
| Base URL | `graph.facebook.com` | `graph.instagram.com` |
|
|
13
|
+
| Account identifier | Page-scoped user id | Instagram-scoped user id |
|
|
14
|
+
| Scopes | Page messaging scopes | `instagram_business_basic`, `instagram_business_manage_messages` |
|
|
15
|
+
| Supported here | Yes | Yes |
|
|
16
|
+
|
|
17
|
+
`FlowChat::Instagram::Configuration#login` picks the path: `:facebook` (the default) or `:instagram`. Everything a flow touches is identical either way: the renderer, the limits, the choice mapping, the sessions, the instrumentation. `app.platform` is always `:instagram`. Only the transport and the credentials differ: on `:facebook` the client posts to `graph.facebook.com` and authenticates with the Page access token; on `:instagram` it posts to `graph.instagram.com` and authenticates with the Instagram User access token.
|
|
18
|
+
|
|
19
|
+
Inbound matching does not differ. A delivery arrives under the `instagram` webhook object on both paths, and names the Instagram professional account in `entry.id` — not the linked Page, even when there is one. So an inbound delivery is always matched against `instagram_account_id`, which is why that field is required whichever path you configure.
|
|
20
|
+
|
|
21
|
+
The Instagram Login path cannot do everything the Facebook Login path can: it has no access to ads that click into an Instagram DM and no access to conversation tagging, both of which stay tied to the Facebook Login path in Meta's own product boundaries. Pick Instagram Login only when the professional account genuinely has no linked Facebook Page; otherwise Facebook Login keeps every capability available.
|
|
22
|
+
|
|
23
|
+
Instagram shares its webhook envelope and most of its rendering logic with Messenger (`FlowChat::Meta::MessagingGateway`), but has its own configuration, client and limits, and one crucial rendering difference: Instagram's interactive surfaces do not render everywhere, described below.
|
|
24
|
+
|
|
25
|
+
## Credentials
|
|
26
|
+
|
|
27
|
+
The gateway needs an access token, a verify token, and `instagram_account_id`; an app secret is needed to validate webhook signatures. `instagram_account_id` is required on both paths, because that is the id every inbound delivery names.
|
|
28
|
+
|
|
29
|
+
On the default `:facebook` path you also need `page_id`, since that is what an outbound send is addressed as. On `:instagram` there is no Page, and `instagram_account_id` serves both roles. A configuration missing either required id reports itself invalid rather than answering the webhook handshake and then rejecting the traffic that follows.
|
|
30
|
+
|
|
31
|
+
```yaml
|
|
32
|
+
# config/credentials.yml.enc
|
|
33
|
+
instagram:
|
|
34
|
+
login: "facebook" # or "instagram"; defaults to "facebook" if omitted
|
|
35
|
+
access_token: "..."
|
|
36
|
+
page_id: "..." # the Facebook Page the Instagram account is linked to; required when login is "facebook"
|
|
37
|
+
instagram_account_id: "..." # the Instagram professional account id; required when login is "instagram"
|
|
38
|
+
verify_token: "..." # your own value, echoed back during webhook setup
|
|
39
|
+
app_id: "..." # used to classify echoes as :self, see below
|
|
40
|
+
app_secret: "..." # used to verify X-Hub-Signature-256
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Equivalent environment variables: `INSTAGRAM_LOGIN`, `INSTAGRAM_ACCESS_TOKEN`, `INSTAGRAM_PAGE_ID`, `INSTAGRAM_ACCOUNT_ID`, `INSTAGRAM_VERIFY_TOKEN`, `INSTAGRAM_APP_ID`, `INSTAGRAM_APP_SECRET`.
|
|
44
|
+
|
|
45
|
+
### Which app id and secret, on the Instagram Login path
|
|
46
|
+
|
|
47
|
+
**This is not settled, and the consequences of getting it wrong are quiet, so read
|
|
48
|
+
this before going live on the Instagram Login path.**
|
|
49
|
+
|
|
50
|
+
Meta's App Dashboard shows the Instagram product its own app id and app secret, on
|
|
51
|
+
the Instagram product's settings page rather than under App settings. So there are
|
|
52
|
+
two candidate pairs for `app_id` and `app_secret` on this path: the app's, and the
|
|
53
|
+
Instagram product's.
|
|
54
|
+
|
|
55
|
+
What signs an Instagram Login webhook is unconfirmed. Meta's Instagram webhooks page
|
|
56
|
+
says to generate the signature with "your app's App Secret" from App settings, and
|
|
57
|
+
names no separate Instagram secret. Against that, the Instagram product plainly has
|
|
58
|
+
its own secret, and it would be odd for it to exist and sign nothing. Neither the
|
|
59
|
+
documentation nor any delivery we have seen settles it.
|
|
60
|
+
|
|
61
|
+
Both values fail quietly if wrong, in different ways:
|
|
62
|
+
|
|
63
|
+
- A wrong `app_secret` makes every delivery fail `X-Hub-Signature-256` and look
|
|
64
|
+
forged. The gateway drops it and answers 200, so the symptom is a bot that receives
|
|
65
|
+
nothing while Meta's dashboard reports successful deliveries. `Meta::MessagingGateway`
|
|
66
|
+
logs a warning naming the failure, so the log tells you.
|
|
67
|
+
- A wrong `app_id` misclassifies echoes. `echo_origin` compares an echo's `app_id`
|
|
68
|
+
against this one, so if sends on this path carry the Instagram app id and the app's
|
|
69
|
+
is configured, your own replies come back as `:other_app` rather than `:self`. An
|
|
70
|
+
application that stands its flow down when another sender appears would then stand
|
|
71
|
+
down on its own messages. Whether sends on this path carry the Instagram app id is
|
|
72
|
+
also unconfirmed.
|
|
73
|
+
|
|
74
|
+
**How to settle it:** send one message and let one delivery arrive. If deliveries drop
|
|
75
|
+
with a signature warning, the other secret is the right one. If your own sends echo
|
|
76
|
+
back as `:other_app`, the other app id is.
|
|
77
|
+
|
|
78
|
+
**For one endpoint serving several accounts, do not pick.** The signature has to be
|
|
79
|
+
checked before the delivery says whose it is, so there is no configuration to read a
|
|
80
|
+
secret from yet. `FlowChat::Meta::Signature.valid?(body, header, secret)` takes the
|
|
81
|
+
secret as an argument for that reason: a caller can try each secret an account of
|
|
82
|
+
theirs could legitimately have used, and accept the delivery if any matches. That is
|
|
83
|
+
correct whichever secret Meta actually signs with, which is why it is the better
|
|
84
|
+
answer than choosing.
|
|
85
|
+
|
|
86
|
+
On the `facebook` login path, use the app's own pair, as for Messenger and WhatsApp.
|
|
87
|
+
|
|
88
|
+
One endpoint serving several accounts has a harder version of this problem: the
|
|
89
|
+
signature has to be checked before the delivery says whose it is, so there is no
|
|
90
|
+
configuration to read the secret from yet. `FlowChat::Meta::Signature.valid?(body,
|
|
91
|
+
header, secret)` exists for that, taking the secret as an argument so a caller can
|
|
92
|
+
try each one an account of theirs could legitimately have used.
|
|
93
|
+
|
|
94
|
+
Setting `login` to anything other than `:facebook` or `:instagram` raises `ArgumentError` rather than falling back silently: a typo here would otherwise pick the wrong host and the wrong account id without any error until a send or a webhook actually failed against it.
|
|
95
|
+
|
|
96
|
+
## Setup
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
# app/controllers/instagram_controller.rb
|
|
100
|
+
class InstagramController < ApplicationController
|
|
101
|
+
skip_forgery_protection
|
|
102
|
+
|
|
103
|
+
def webhook
|
|
104
|
+
processor = FlowChat::Processor.new(self) do |config|
|
|
105
|
+
config.use_gateway FlowChat::Instagram::Gateway::SendApi
|
|
106
|
+
config.use_session_store FlowChat::Session::CacheSessionStore
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
processor.run RegistrationFlow, :main_page
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
# config/routes.rb
|
|
116
|
+
match "/instagram/webhook", to: "instagram#webhook", via: [:get, :post]
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
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.
|
|
120
|
+
|
|
121
|
+
With no second argument, `use_gateway` loads credentials through `FlowChat::Instagram::Configuration.from_credentials`, which reads the Rails credentials or environment variables above. That is the setup shown here.
|
|
122
|
+
|
|
123
|
+
### The webhook `object` field
|
|
124
|
+
|
|
125
|
+
Every delivery carries a top-level `object` field naming which subscription it came from. Messenger's is always `"page"`. Meta's own documentation is ambiguous about whether Instagram messaging events delivered via the Facebook Login path arrive under `"page"` or `"instagram"`, and this is not something FlowChat can settle for you: it depends on how your Meta app is configured. `FlowChat::Instagram::Gateway::SendApi#expected_webhook_object` reads `login` off its configuration and answers from `FACEBOOK_LOGIN_WEBHOOK_OBJECT` or `INSTAGRAM_LOGIN_WEBHOOK_OBJECT`, both `"instagram"` by default; confirm the real value against your app's dashboard for whichever path you use, and override the method on a subclass if it disagrees. The two constants are kept separate on purpose: a correction to one path's value, once you confirm it against your dashboard, must not silently change the other's. A delivery whose `object` does not match is dropped with `200 OK`, not an error, so a wrong value here fails silently rather than loudly.
|
|
126
|
+
|
|
127
|
+
### Webhook fields
|
|
128
|
+
|
|
129
|
+
As with Messenger, subscribe at least:
|
|
130
|
+
|
|
131
|
+
- `messages`: text, quick-reply taps, attachments, and message echoes.
|
|
132
|
+
- `messaging_postbacks`: carousel button taps.
|
|
133
|
+
|
|
134
|
+
Optionally, `message_deliveries` and `message_reads` surface as `MESSAGE_STATUS` events. Anything else you subscribe to arrives through `WEBHOOK_RECEIVED`, unmodelled, for your own code to interpret.
|
|
135
|
+
|
|
136
|
+
## Explicit and multi-account configuration
|
|
137
|
+
|
|
138
|
+
To run more than one linked account, or to load credentials from somewhere other than Rails credentials, build a `FlowChat::Instagram::Configuration` and pass it as the second argument to `use_gateway`.
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
config = FlowChat::Instagram::Configuration.new(:support).tap do |c|
|
|
142
|
+
c.login = :instagram # or :facebook, the default
|
|
143
|
+
c.access_token = tenant.instagram_access_token
|
|
144
|
+
c.instagram_account_id = tenant.instagram_account_id
|
|
145
|
+
c.verify_token = tenant.instagram_verify_token
|
|
146
|
+
c.app_id = tenant.instagram_app_id
|
|
147
|
+
c.app_secret = tenant.instagram_app_secret
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
processor = FlowChat::Processor.new(self) do |cfg|
|
|
151
|
+
cfg.use_gateway FlowChat::Instagram::Gateway::SendApi, config
|
|
152
|
+
cfg.use_session_store FlowChat::Session::CacheSessionStore
|
|
153
|
+
end
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Passing a name to `new` registers the configuration under that name, so you can retrieve it later with `FlowChat::Instagram::Configuration.get(:support)`. For an unnamed configuration, use `FlowChat::Instagram::Configuration.new(nil)`. The configuration attributes are `login`, `access_token`, `page_id`, `instagram_account_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).
|
|
157
|
+
|
|
158
|
+
## The flow is the same
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
class RegistrationFlow < FlowChat::Flow
|
|
162
|
+
def main_page
|
|
163
|
+
name = app.screen(:name) { |prompt| prompt.ask "What's your name?" }
|
|
164
|
+
|
|
165
|
+
plan = app.screen(:plan) do |prompt|
|
|
166
|
+
prompt.select "Choose a plan", { "basic" => "Basic", "pro" => "Pro" }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
app.say "Welcome #{name}!"
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`app.msisdn` is always `nil` on Instagram; there is no phone number in the IGSID Meta assigns a user. Use `app.user_id` (the IGSID) as the stable per-user identifier, which is also what sessions key on by default.
|
|
175
|
+
|
|
176
|
+
## How choices render
|
|
177
|
+
|
|
178
|
+
Instagram quick replies and the carousel (generic template) both render on the Instagram mobile app only, not on desktop or web. A user without the mobile app who reaches a screen with tappable-only options has no way to answer at all, so Instagram's renderer always lists the options as a numbered body as well as rendering the tappable surface, and a typed number is always accepted:
|
|
179
|
+
|
|
180
|
+
| Choices | Rendered as |
|
|
181
|
+
|---|---|
|
|
182
|
+
| 0 | Plain text, split at 1000 bytes UTF-8 |
|
|
183
|
+
| 1 to 13 | Quick replies, plus the same options numbered in the message body |
|
|
184
|
+
| 14 to 30 | A carousel, packed as postback buttons across generic-template cards, plus the same options numbered in the message body |
|
|
185
|
+
| 31 or more | Numbered text only; there is no tappable surface above 30 |
|
|
186
|
+
|
|
187
|
+
13 is Meta's cap on quick replies per message; 30 is 10 carousel elements times 3 buttons per element. 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.
|
|
188
|
+
|
|
189
|
+
The numbered body and the numbering on a quick-reply or carousel button title are two separate things that happen to usually appear together. The body is always numbered, on every rung, because a desktop user with no tappable surface at all still needs a way to answer. The button's own title, by contrast, is only prefixed with a position when it needs to be: FlowChat truncates each title to fit (20 characters) and checks the whole set, and if any title had to be truncated, or if two choices land on the same title, every title in the set gets prefixed, not just the ones that collided. A short menu of distinct options (`Yes` / `No`) has an unprefixed title even though the body right next to it still reads "1. Yes\n2. No"; a menu with a long label, or with two choices sharing a label, gets both the title and the body numbered. This is decided across the whole choice set, not per carousel card, the same as on Messenger.
|
|
190
|
+
|
|
191
|
+
A user can reply to any screen with choices by tapping, by typing the title exactly as shown, or by typing the position number - the number always works here, because the body always shows one, even on an unprefixed screen. 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 30 choices, where there is no tappable surface, the number in the body is the only way to reply.
|
|
192
|
+
|
|
193
|
+
## Media
|
|
194
|
+
|
|
195
|
+
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:
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
photo = app.media.first
|
|
199
|
+
if photo
|
|
200
|
+
photo.type # => :image
|
|
201
|
+
bytes = photo.download
|
|
202
|
+
end
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Signed CDN URLs expire; fetch `download` during the turn it arrives rather than from a session-stored answer later.
|
|
206
|
+
|
|
207
|
+
Send media outbound by passing `media:` to `ask` or `say`:
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
app.say "Here is the map", media: { type: :image, url: "https://example.com/map.png" }
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
`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.
|
|
214
|
+
|
|
215
|
+
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. Instagram's always-numbered body still applies on top: a mobile user gets the image, then tappable quick replies (or a carousel) with the options numbered in the body next to them, and a desktop user gets the image, then the numbered body with nothing tappable, same as with no media at all.
|
|
216
|
+
|
|
217
|
+
## Echoes and coexistence
|
|
218
|
+
|
|
219
|
+
Instagram reports every message sent on a thread, including one typed by a human in the linked inbox and one sent by a different app connected to the same account, 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`:
|
|
220
|
+
|
|
221
|
+
| `echo_origin` | Meaning |
|
|
222
|
+
|---|---|
|
|
223
|
+
| `:self` | The echo's `app_id` matches this configuration's `app_id`. Our own send coming back. |
|
|
224
|
+
| `:other_app` | An `app_id` is present but does not match. Another connected app sent it. |
|
|
225
|
+
| `:human_agent` | No `app_id` at all. A person replying from the linked inbox. |
|
|
226
|
+
|
|
227
|
+
`: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.
|
|
228
|
+
|
|
229
|
+
## Who can be on each side
|
|
230
|
+
|
|
231
|
+
The account running the flow must be an Instagram **professional** account, Business or Creator. A linked Facebook Page is required on the `:facebook` login path and not required on the `:instagram` path. A personal Instagram account cannot be the business side of a conversation on either path: Meta's messaging API does not accept one, and there is no FlowChat setting that works around it.
|
|
232
|
+
|
|
233
|
+
The person on the other side is an ordinary Instagram user, which is the normal case and needs nothing from them.
|
|
234
|
+
|
|
235
|
+
Group threads are not supported. The webhook envelope pairs one sender with one recipient, and Meta does not expose group threads through this API.
|
|
236
|
+
|
|
237
|
+
## The user has to speak first
|
|
238
|
+
|
|
239
|
+
Meta only permits a send once the user has messaged the professional account: "only after an Instagram user has sent your app user's Instagram professional account a message can your app send a message to the Instagram user."
|
|
240
|
+
|
|
241
|
+
A flow therefore cannot open an Instagram conversation. There is no Instagram equivalent of an outbound-first WhatsApp template, so anything resembling a notification or a reminder has to begin with the user, or reach them on a platform that allows it. `FlowChat::Factory` and out-of-band sends through `context["instagram.client"]` are both bound by this: they can continue a conversation the user started, not start one.
|
|
242
|
+
|
|
243
|
+
## The 24-hour window
|
|
244
|
+
|
|
245
|
+
Separately from the rule above, Meta restricts free-form Instagram 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["instagram.client"]`'s `send_message` or `send_text`:
|
|
246
|
+
|
|
247
|
+
```ruby
|
|
248
|
+
context["instagram.client"].send_message(igsid, "Following up on your case", tag: "HUMAN_AGENT")
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
`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.
|
|
252
|
+
|
|
253
|
+
Instagram's client never sends `messaging_type` on an untagged send, since Meta's Instagram reference does not document that field at all. A tagged send is the exception: Meta does document `MESSAGE_TAG` with `HUMAN_AGENT` for Instagram, so a tagged send sets `messaging_type: "MESSAGE_TAG"` and `tag: "HUMAN_AGENT"` even though nothing else here ever sets `messaging_type`. 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.
|
|
254
|
+
|
|
255
|
+
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.
|
|
256
|
+
|
|
257
|
+
## Limits
|
|
258
|
+
|
|
259
|
+
| Area | Behavior on Instagram |
|
|
260
|
+
|---|---|
|
|
261
|
+
| Text length | Under 1000 bytes UTF-8 (measured in bytes, not characters, so multibyte text has a lower character budget), split into multiple messages above that |
|
|
262
|
+
| Quick replies | 13 per message, title truncated to 20 characters, mobile app only |
|
|
263
|
+
| Carousel | 10 elements, 3 postback buttons per element, button title truncated to 20 characters, mobile app only |
|
|
264
|
+
| Choice payload | Generated ids are capped at 1000 characters |
|
|
265
|
+
| Attachments | One per inbound message is read (the first); outbound is one attachment per send |
|
|
266
|
+
| Media with choices | Sent as its own message ahead of the choice message; does not change which rung renders |
|
|
267
|
+
| 24-hour window | Not tracked automatically; `tag:` is passed through unvalidated, see above |
|
|
268
|
+
|
|
269
|
+
## Async
|
|
270
|
+
|
|
271
|
+
Instagram supports background processing with `use_async`. See [factory-pattern.md](../factory-pattern.md) and [async-background-processing.md](../async-background-processing.md).
|
|
272
|
+
|
|
273
|
+
## Related
|
|
274
|
+
|
|
275
|
+
- [Messenger](messenger.md)
|
|
276
|
+
- [Getting started](../getting-started.md)
|
|
277
|
+
- [Configuration](../configuration.md)
|
|
278
|
+
- [Instrumentation](../instrumentation.md)
|
|
@@ -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)
|