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
data/docs/instrumentation.md
CHANGED
|
@@ -1,216 +1,136 @@
|
|
|
1
|
-
# Instrumentation
|
|
1
|
+
# Instrumentation
|
|
2
2
|
|
|
3
|
-
FlowChat
|
|
3
|
+
FlowChat emits `ActiveSupport::Notifications` events at each stage of a request, so you can feed metrics, traces, and structured logs into your own backend. The events fire whether or not anything subscribes; FlowChat also ships a log subscriber and a metrics collector that subscribe for you.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Event names
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Every FlowChat event is published under its name with a `.flow_chat` suffix. The name in the table is what you pass to `instrument`; the string you subscribe to adds the suffix, for example `flow.execution.end.flow_chat`.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
| Event | When it fires |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `flow.execution.start` | A flow action begins. |
|
|
12
|
+
| `flow.execution.end` | A flow action finishes (carries `duration`). |
|
|
13
|
+
| `flow.execution.error` | A flow action raised. |
|
|
14
|
+
| `context.created` | A request context is built. |
|
|
15
|
+
| `session.created` | A session is created. |
|
|
16
|
+
| `session.destroyed` | A session is destroyed (flow terminated). |
|
|
17
|
+
| `session.data.get` / `session.data.set` | A session value is read or written. |
|
|
18
|
+
| `session.cache.hit` / `session.cache.miss` | A session cache lookup. |
|
|
19
|
+
| `message.received` | An inbound message arrives (text or an attachment). |
|
|
20
|
+
| `message.sent` | A response is sent to the user. |
|
|
21
|
+
| `message.delivery_failed` | A reply the flow produced that the platform would not take. |
|
|
22
|
+
| `message.status` | A platform's own report of what became of a message we sent. |
|
|
23
|
+
| `webhook.verified` / `webhook.failed` | A gateway verified or rejected a webhook. |
|
|
24
|
+
| `api.request` / `api.error` | An outbound platform API call, or its failure. |
|
|
25
|
+
| `media.upload` | Media is uploaded to a platform. |
|
|
26
|
+
| `pagination.triggered` | A USSD response was split into pages. |
|
|
27
|
+
| `webhook.received` | A verified webhook this gateway does not model, handed on whole. |
|
|
13
28
|
|
|
14
|
-
|
|
15
|
-
- 📊 **Metrics Collection** - Performance and usage metrics
|
|
16
|
-
- 📝 **Structured Logging** - Event-driven logs with context
|
|
17
|
-
- 🔍 **Event Tracking** - All framework events instrumented
|
|
18
|
-
- ⚡ **Performance Monitoring** - Execution timing and bottleneck detection
|
|
19
|
-
|
|
20
|
-
## Features
|
|
21
|
-
|
|
22
|
-
**🎯 Zero Configuration**
|
|
23
|
-
- Works out of the box with Rails applications
|
|
24
|
-
- Automatic ActiveSupport::Notifications integration
|
|
25
|
-
- Thread-safe metrics collection
|
|
26
|
-
|
|
27
|
-
**📈 Comprehensive Metrics**
|
|
28
|
-
- Flow execution counts and timing
|
|
29
|
-
- Session creation/destruction rates
|
|
30
|
-
- WhatsApp/USSD message volumes
|
|
31
|
-
- Cache hit/miss ratios
|
|
32
|
-
- Error tracking by type and flow
|
|
33
|
-
|
|
34
|
-
**🔍 Rich Event Tracking**
|
|
35
|
-
- 20+ predefined event types (see [Event Types](#event-types))
|
|
36
|
-
- Automatic context enrichment (session ID, flow name, gateway)
|
|
37
|
-
- Structured event payloads
|
|
38
|
-
|
|
39
|
-
**📊 Production Ready**
|
|
40
|
-
- Minimal performance overhead
|
|
41
|
-
- Thread-safe operations
|
|
42
|
-
- Graceful error handling
|
|
43
|
-
|
|
44
|
-
## Event Types
|
|
45
|
-
|
|
46
|
-
FlowChat instruments the following events:
|
|
47
|
-
|
|
48
|
-
### Flow Events
|
|
49
|
-
- `flow.execution.start.flow_chat`
|
|
50
|
-
- `flow.execution.end.flow_chat`
|
|
51
|
-
- `flow.execution.error.flow_chat`
|
|
52
|
-
|
|
53
|
-
### Session Events
|
|
54
|
-
- `session.created.flow_chat`
|
|
55
|
-
- `session.destroyed.flow_chat`
|
|
56
|
-
- `session.data.get.flow_chat`
|
|
57
|
-
- `session.data.set.flow_chat`
|
|
58
|
-
- `session.cache.hit.flow_chat`
|
|
59
|
-
- `session.cache.miss.flow_chat`
|
|
60
|
-
|
|
61
|
-
### WhatsApp Events
|
|
62
|
-
- `whatsapp.message.received.flow_chat`
|
|
63
|
-
- `whatsapp.message.sent.flow_chat`
|
|
64
|
-
- `whatsapp.webhook.verified.flow_chat`
|
|
65
|
-
- `whatsapp.api.request.flow_chat`
|
|
66
|
-
- `whatsapp.media.upload.flow_chat`
|
|
67
|
-
|
|
68
|
-
### USSD Events
|
|
69
|
-
- `ussd.message.received.flow_chat`
|
|
70
|
-
- `ussd.message.sent.flow_chat`
|
|
71
|
-
- `ussd.pagination.triggered.flow_chat`
|
|
72
|
-
|
|
73
|
-
## Usage Examples
|
|
74
|
-
|
|
75
|
-
### Access Metrics
|
|
29
|
+
Payloads are enriched with `request_id`, `session_id`, `flow_name`, `gateway`, and `platform` when the context has them, plus a `timestamp`.
|
|
76
30
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
# Flow execution metrics
|
|
82
|
-
flow_metrics = FlowChat.metrics.get_category("flows")
|
|
83
|
-
puts flow_metrics["flows.executed"] # Total flows executed
|
|
84
|
-
puts flow_metrics["flows.execution_time"] # Average execution time
|
|
85
|
-
|
|
86
|
-
# Session metrics
|
|
87
|
-
session_metrics = FlowChat.metrics.get_category("sessions")
|
|
88
|
-
puts session_metrics["sessions.created"] # Total sessions created
|
|
89
|
-
puts session_metrics["sessions.cache.hits"] # Cache hit count
|
|
90
|
-
```
|
|
31
|
+
## Webhooks that are not messaging
|
|
32
|
+
|
|
33
|
+
FlowChat's job is messaging: the inbound turn, the reply it produces, and what became of that reply. A platform sends a great deal more. WhatsApp alone will report account bans, template approvals, phone number quality, imported chat history, contact address books, and replies a human typed in the WhatsApp Business App, and it adds new fields regularly.
|
|
91
34
|
|
|
92
|
-
|
|
35
|
+
None of that is a customer turn, so **none of it runs a flow**, and none of it is interpreted here. FlowChat verifies the signature, answers the platform, and publishes the change under `webhook.received` with the field that named it. What it means is your application's decision:
|
|
93
36
|
|
|
94
37
|
```ruby
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
# Event automatically includes session_id, flow_name, gateway
|
|
107
|
-
result
|
|
108
|
-
end
|
|
38
|
+
ActiveSupport::Notifications.subscribe("webhook.received.flow_chat") do |*, payload|
|
|
39
|
+
case payload[:field]
|
|
40
|
+
when "smb_message_echoes"
|
|
41
|
+
# A human answered from the WhatsApp Business App. Most applications will want
|
|
42
|
+
# to stop the bot replying on top of them.
|
|
43
|
+
MyApp::Echoes.record(payload[:business_phone_number_id], payload[:value]["message_echoes"])
|
|
44
|
+
when "history"
|
|
45
|
+
MyApp::HistoryImport.enqueue(payload[:business_phone_number_id], payload[:value]["history"])
|
|
46
|
+
when "account_update"
|
|
47
|
+
MyApp::Connections.review(payload[:business_phone_number_id], payload[:value])
|
|
109
48
|
end
|
|
110
49
|
end
|
|
111
50
|
```
|
|
112
51
|
|
|
113
|
-
|
|
52
|
+
The payload carries `field`, the whole `value`, and the business phone number when the change names one. Account-level changes do not name one, so expect it to be nil there.
|
|
53
|
+
|
|
54
|
+
Subscribing to a field costs nothing here: an unmodelled field is published whether or not anything listens, and logged at info so a subscription you have not written yet is still visible. Adding support for a new field is a change in your application, not a new release of this gem.
|
|
55
|
+
|
|
56
|
+
## Reacting to `api.error`
|
|
57
|
+
|
|
58
|
+
The `message` on an `api.error` payload is prose, written for someone reading
|
|
59
|
+
logs. Do not branch on it: rewording a log line would change your behaviour.
|
|
60
|
+
Read these instead.
|
|
61
|
+
|
|
62
|
+
| key | meaning |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `error_class` | The exception's class, whenever one was raised. |
|
|
65
|
+
| `error_type` | What kind of failure it is, named by the adapter. Intercom reports `authentication`, `resource_not_found` and `server_error`; WhatsApp passes through Meta's own `type`, such as `OAuthException`. |
|
|
66
|
+
| `error_code` | The platform's own code. Telegram's `error_code`, Meta's `code`, Intercom's HTTP status. |
|
|
67
|
+
|
|
68
|
+
WhatsApp also carries `error_subcode` and `error_message` from Meta, and
|
|
69
|
+
Telegram carries `error_description`. Identify the connection from
|
|
70
|
+
`phone_number_id`, `bot_id` or `app_id` as appropriate.
|
|
71
|
+
|
|
72
|
+
Not every failure reports. Network timeouts are re-raised so your own retry
|
|
73
|
+
logic sees them, and an Intercom rate limit raises `RateLimitError` rather than
|
|
74
|
+
reporting, so a subscriber reacting to `api.error` will not mistake either for
|
|
75
|
+
a dead credential.
|
|
114
76
|
|
|
115
77
|
```ruby
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
# Subscribe to specific events
|
|
120
|
-
ActiveSupport::Notifications.subscribe("flow.execution.end.flow_chat") do |event|
|
|
121
|
-
duration = event.duration
|
|
122
|
-
flow_name = event.payload[:flow_name]
|
|
123
|
-
|
|
124
|
-
# Send to external monitoring service
|
|
125
|
-
ExternalMonitoring.track_flow_execution(flow_name, duration)
|
|
126
|
-
end
|
|
78
|
+
ActiveSupport::Notifications.subscribe("api.error.flow_chat") do |*, payload|
|
|
79
|
+
next unless payload[:error_type] == "authentication"
|
|
127
80
|
|
|
128
|
-
|
|
129
|
-
ActiveSupport::Notifications.subscribe(/\.flow_chat$/) do |name, start, finish, id, payload|
|
|
130
|
-
CustomLogger.log_event(name, payload.merge(duration: finish - start))
|
|
81
|
+
AlertOwner.call(platform: payload[:platform], app_id: payload[:app_id])
|
|
131
82
|
end
|
|
132
83
|
```
|
|
133
84
|
|
|
134
|
-
|
|
85
|
+
## Delivery callbacks
|
|
86
|
+
|
|
87
|
+
Events are broadcasts and carry no context, because anyone may subscribe and the context holds the gateway client and the raw inbound body. When the application that owns the turn needs to reach its own records, it uses a callback instead.
|
|
88
|
+
|
|
89
|
+
A gateway that delivers out of band (Telegram, WhatsApp, Intercom) sends after the middleware stack has unwound. So a row the application wrote during the turn was written before anything knew whether the send worked, or what the platform would call it. These two callbacks are the only places that know:
|
|
135
90
|
|
|
136
91
|
```ruby
|
|
137
|
-
|
|
138
|
-
FlowChat
|
|
139
|
-
|
|
140
|
-
# Export metrics to Prometheus, StatsD, etc.
|
|
141
|
-
ActiveSupport::Notifications.subscribe("flow.execution.end.flow_chat") do |event|
|
|
142
|
-
StatsD.increment("flowchat.flows.executed")
|
|
143
|
-
StatsD.timing("flowchat.flows.duration", event.duration)
|
|
144
|
-
StatsD.increment("flowchat.flows.#{event.payload[:flow_name]}.executed")
|
|
92
|
+
FlowChat::Config.on_delivery_success = lambda do |context, result|
|
|
93
|
+
id = context[FlowChat::Instrumentation::DELIVERED_MESSAGE_ID_KEY]
|
|
94
|
+
MyApp::Message.find(context["myapp.bot_message_id"]).update!(platform_message_id: id) if id
|
|
145
95
|
end
|
|
146
96
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
StatsD.increment("flowchat.flows.errors")
|
|
150
|
-
StatsD.increment("flowchat.flows.errors.#{event.payload[:error_class]}")
|
|
97
|
+
FlowChat::Config.on_delivery_failure = lambda do |context, error|
|
|
98
|
+
MyApp::Message.find(context["myapp.bot_message_id"]).update!(status: :failed, error: error.message)
|
|
151
99
|
end
|
|
152
100
|
```
|
|
153
101
|
|
|
154
|
-
|
|
102
|
+
`DELIVERED_MESSAGE_ID_KEY` is `"delivery.platform_message_id"`, and every out-of-band gateway sets it to whatever its own platform called the message: WhatsApp's `wamid`, Telegram's numeric `message_id`, Intercom's conversation part id. It is nil when a platform names none. Reading one key is the point, so an application does not carry a case statement over platforms.
|
|
155
103
|
|
|
156
|
-
|
|
104
|
+
HTTP and USSD set nothing, since their reply travels in the response they are already returning and has no separate delivery to succeed or fail.
|
|
157
105
|
|
|
158
|
-
|
|
159
|
-
- **Metrics Collection**: Thread-safe atomic operations
|
|
160
|
-
- **Memory Usage**: <1MB for typical applications
|
|
161
|
-
- **Storage**: Events are ephemeral, metrics are kept in memory
|
|
106
|
+
Neither callback may change what happened. `on_delivery_success` cannot alter the send's return value, `on_delivery_failure` cannot replace the delivery error, and an exception raised in either is logged and dropped.
|
|
162
107
|
|
|
163
|
-
##
|
|
108
|
+
## Subscribing
|
|
164
109
|
|
|
165
|
-
|
|
110
|
+
Subscribe with `ActiveSupport::Notifications`, remembering the `.flow_chat` suffix:
|
|
166
111
|
|
|
167
112
|
```ruby
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
### Reset Metrics
|
|
113
|
+
ActiveSupport::Notifications.subscribe("flow.execution.end.flow_chat") do |*, payload|
|
|
114
|
+
StatsD.timing("flow_chat.flow.#{payload[:flow_name]}", payload[:duration])
|
|
115
|
+
end
|
|
173
116
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
117
|
+
ActiveSupport::Notifications.subscribe("message.received.flow_chat") do |*, payload|
|
|
118
|
+
StatsD.increment("flow_chat.message.received.#{payload[:platform]}")
|
|
119
|
+
end
|
|
177
120
|
```
|
|
178
121
|
|
|
179
|
-
|
|
122
|
+
## Built-in metrics
|
|
123
|
+
|
|
124
|
+
`FlowChat.metrics` returns a metrics collector that subscribes to the events above and keeps running counters and timings (flows executed, errors by class, sessions created by gateway, cache hits, and so on). Read a snapshot:
|
|
180
125
|
|
|
181
126
|
```ruby
|
|
182
|
-
#
|
|
183
|
-
|
|
127
|
+
FlowChat.metrics.snapshot # => a Hash of counters and timings
|
|
128
|
+
FlowChat.metrics.get_category("flows") # => just the flows.* metrics
|
|
184
129
|
```
|
|
185
130
|
|
|
186
|
-
|
|
131
|
+
FlowChat also ships a `LogSubscriber` that logs the same events through `FlowChat::Config.logger`. Both are wired up by `FlowChat::Instrumentation::Setup`; call `FlowChat.setup_instrumentation!` during boot to enable them, or access `FlowChat.metrics` to start the collector on first use.
|
|
187
132
|
|
|
188
|
-
|
|
189
|
-
# test/test_helper.rb
|
|
190
|
-
class ActiveSupport::TestCase
|
|
191
|
-
setup do
|
|
192
|
-
# Reset metrics before each test
|
|
193
|
-
FlowChat::Instrumentation::Setup.reset! if FlowChat::Instrumentation::Setup.setup?
|
|
194
|
-
end
|
|
195
|
-
end
|
|
133
|
+
## Related
|
|
196
134
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
test "flow execution is instrumented" do
|
|
200
|
-
events = []
|
|
201
|
-
|
|
202
|
-
# Capture events
|
|
203
|
-
ActiveSupport::Notifications.subscribe(/flow_chat$/) do |name, start, finish, id, payload|
|
|
204
|
-
events << { name: name, payload: payload, duration: (finish - start) * 1000 }
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
# Execute flow
|
|
208
|
-
processor.run(WelcomeFlow, :main_page)
|
|
209
|
-
|
|
210
|
-
# Verify events
|
|
211
|
-
assert_equal 2, events.size
|
|
212
|
-
assert_equal "flow.execution.start.flow_chat", events[0][:name]
|
|
213
|
-
assert_equal "flow.execution.end.flow_chat", events[1][:name]
|
|
214
|
-
assert_equal "welcome_flow", events[0][:payload][:flow_name]
|
|
215
|
-
end
|
|
216
|
-
end
|
|
135
|
+
- [Configuration](configuration.md)
|
|
136
|
+
- [Architecture](architecture.md)
|
|
@@ -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)
|