flow_chat 0.8.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. checksums.yaml +4 -4
  2. data/.cliff.toml +74 -0
  3. data/.github/workflows/ci.yml +2 -3
  4. data/.github/workflows/pages.yml +43 -0
  5. data/.github/workflows/release.yml +56 -0
  6. data/.standard.yml +4 -0
  7. data/CHANGELOG.md +48 -0
  8. data/CLAUDE.md +327 -0
  9. data/CONTRIBUTING.md +134 -0
  10. data/Gemfile +1 -0
  11. data/README.md +189 -133
  12. data/Rakefile +17 -2
  13. data/SECURITY.md +42 -349
  14. data/docs/architecture.md +83 -0
  15. data/docs/async-background-processing.md +64 -0
  16. data/docs/configuration.md +110 -287
  17. data/docs/factory-pattern.md +58 -0
  18. data/docs/gateway-context-variables.md +168 -0
  19. data/docs/gateway-development.md +159 -0
  20. data/docs/getting-started.md +90 -0
  21. data/docs/instrumentation.md +95 -175
  22. data/docs/platforms/instagram.md +278 -0
  23. data/docs/platforms/messenger.md +205 -0
  24. data/docs/platforms/telegram.md +109 -0
  25. data/docs/platforms/ussd.md +78 -0
  26. data/docs/platforms/whatsapp.md +147 -0
  27. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md +732 -0
  28. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json +58 -0
  29. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md +4064 -0
  30. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json +226 -0
  31. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md +972 -0
  32. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json +88 -0
  33. data/docs/superpowers/specs/2026-07-09-inbound-media-support-design.md +195 -0
  34. data/docs/superpowers/specs/2026-08-10-messenger-instagram-design.md +391 -0
  35. data/docs/testing.md +33 -426
  36. data/examples/custom_session_id_example.rb +119 -0
  37. data/examples/http_controller.rb +22 -20
  38. data/examples/intercom_configuration_example.rb +113 -0
  39. data/examples/intercom_controller.rb +182 -0
  40. data/examples/multi_tenant_whatsapp_controller.rb +63 -168
  41. data/examples/simulator_controller.rb +0 -1
  42. data/examples/ussd_controller.rb +88 -160
  43. data/examples/whatsapp_controller.rb +18 -17
  44. data/examples/whatsapp_media_examples.rb +27 -79
  45. data/flow_chat.gemspec +4 -0
  46. data/lib/flow_chat/app.rb +211 -0
  47. data/lib/flow_chat/async_job.rb +176 -0
  48. data/lib/flow_chat/choice_titles.rb +95 -0
  49. data/lib/flow_chat/config.rb +126 -23
  50. data/lib/flow_chat/delivery_error.rb +9 -0
  51. data/lib/flow_chat/{base_executor.rb → executor.rb} +6 -11
  52. data/lib/flow_chat/factory.rb +94 -0
  53. data/lib/flow_chat/gateway_async_support.rb +106 -0
  54. data/lib/flow_chat/generic_async_job.rb +30 -0
  55. data/lib/flow_chat/http/configuration_error.rb +9 -0
  56. data/lib/flow_chat/http/gateway/simple.rb +104 -36
  57. data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
  58. data/lib/flow_chat/http/renderer.rb +3 -3
  59. data/lib/flow_chat/input.rb +86 -0
  60. data/lib/flow_chat/instagram/client.rb +32 -0
  61. data/lib/flow_chat/instagram/configuration.rb +147 -0
  62. data/lib/flow_chat/instagram/configuration_error.rb +7 -0
  63. data/lib/flow_chat/instagram/gateway/send_api.rb +63 -0
  64. data/lib/flow_chat/instagram/middleware/choice_mapper.rb +22 -0
  65. data/lib/flow_chat/instagram/renderer.rb +23 -0
  66. data/lib/flow_chat/instrumentation/metrics_collector.rb +6 -1
  67. data/lib/flow_chat/instrumentation/setup.rb +1 -1
  68. data/lib/flow_chat/instrumentation.rb +182 -0
  69. data/lib/flow_chat/intercom/client.rb +161 -0
  70. data/lib/flow_chat/intercom/configuration.rb +102 -0
  71. data/lib/flow_chat/intercom/configuration_error.rb +9 -0
  72. data/lib/flow_chat/intercom/gateway/intercom_api.rb +420 -0
  73. data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
  74. data/lib/flow_chat/intercom/renderer.rb +123 -0
  75. data/lib/flow_chat/media.rb +121 -0
  76. data/lib/flow_chat/messenger/client.rb +264 -0
  77. data/lib/flow_chat/messenger/configuration.rb +103 -0
  78. data/lib/flow_chat/messenger/configuration_error.rb +9 -0
  79. data/lib/flow_chat/messenger/gateway/send_api.rb +42 -0
  80. data/lib/flow_chat/messenger/middleware/choice_mapper.rb +185 -0
  81. data/lib/flow_chat/messenger/renderer.rb +150 -0
  82. data/lib/flow_chat/meta/challenge.rb +24 -0
  83. data/lib/flow_chat/meta/choice_ladder.rb +37 -0
  84. data/lib/flow_chat/meta/configuration_error.rb +7 -0
  85. data/lib/flow_chat/meta/gateway_identity.rb +38 -0
  86. data/lib/flow_chat/meta/messaging_gateway.rb +468 -0
  87. data/lib/flow_chat/meta/signature.rb +30 -0
  88. data/lib/flow_chat/meta/signature_validation.rb +66 -0
  89. data/lib/flow_chat/meta/webhook_verification.rb +43 -0
  90. data/lib/flow_chat/named_configuration.rb +65 -0
  91. data/lib/flow_chat/phone_number_util.rb +37 -35
  92. data/lib/flow_chat/processor.rb +188 -0
  93. data/lib/flow_chat/prompt.rb +13 -16
  94. data/lib/flow_chat/renderers/markdown_support.rb +167 -0
  95. data/lib/flow_chat/security.rb +76 -0
  96. data/lib/flow_chat/session/middleware.rb +36 -11
  97. data/lib/flow_chat/simulator/controller.rb +31 -15
  98. data/lib/flow_chat/simulator/views/simulator.html.erb +184 -20
  99. data/lib/flow_chat/telegram/client.rb +283 -0
  100. data/lib/flow_chat/telegram/configuration.rb +78 -0
  101. data/lib/flow_chat/telegram/configuration_error.rb +9 -0
  102. data/lib/flow_chat/telegram/gateway/bot_api.rb +318 -0
  103. data/lib/flow_chat/telegram/middleware/choice_mapper.rb +96 -0
  104. data/lib/flow_chat/telegram/renderer.rb +133 -0
  105. data/lib/flow_chat/telegram.rb +7 -0
  106. data/lib/flow_chat/text_truncator.rb +75 -0
  107. data/lib/flow_chat/ussd/gateway/nalo.rb +24 -4
  108. data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
  109. data/lib/flow_chat/ussd/middleware/pagination.rb +9 -5
  110. data/lib/flow_chat/ussd/renderer.rb +1 -1
  111. data/lib/flow_chat/version.rb +1 -1
  112. data/lib/flow_chat/whatsapp/client.rb +158 -20
  113. data/lib/flow_chat/whatsapp/configuration.rb +13 -52
  114. data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
  115. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +335 -248
  116. data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +234 -0
  117. data/lib/flow_chat/whatsapp/renderer.rb +259 -64
  118. data/lib/flow_chat.rb +1 -1
  119. data/lib/tasks/release.rake +165 -0
  120. data/site/.nojekyll +0 -0
  121. data/site/.og-card.html +89 -0
  122. data/site/favicon.svg +6 -0
  123. data/site/index.html +209 -0
  124. data/site/og.png +0 -0
  125. metadata +132 -25
  126. data/docs/flows.md +0 -320
  127. data/docs/http-gateway-protocol.md +0 -432
  128. data/docs/images/simulator.png +0 -0
  129. data/docs/media.md +0 -153
  130. data/docs/sessions.md +0 -433
  131. data/docs/ussd-setup.md +0 -322
  132. data/docs/whatsapp-setup.md +0 -162
  133. data/examples/whatsapp_message_job.rb +0 -113
  134. data/lib/flow_chat/base_app.rb +0 -86
  135. data/lib/flow_chat/base_processor.rb +0 -146
  136. data/lib/flow_chat/http/app.rb +0 -6
  137. data/lib/flow_chat/http/middleware/executor.rb +0 -24
  138. data/lib/flow_chat/http/processor.rb +0 -33
  139. data/lib/flow_chat/session/rails_session_store.rb +0 -68
  140. data/lib/flow_chat/ussd/app.rb +0 -6
  141. data/lib/flow_chat/ussd/gateway/nsano.rb +0 -96
  142. data/lib/flow_chat/ussd/middleware/executor.rb +0 -24
  143. data/lib/flow_chat/ussd/processor.rb +0 -39
  144. data/lib/flow_chat/whatsapp/app.rb +0 -29
  145. data/lib/flow_chat/whatsapp/middleware/executor.rb +0 -24
  146. data/lib/flow_chat/whatsapp/processor.rb +0 -32
  147. data/lib/flow_chat/whatsapp/send_job_support.rb +0 -79
@@ -1,216 +1,136 @@
1
- # Instrumentation & Monitoring
1
+ # Instrumentation
2
2
 
3
- FlowChat includes a comprehensive instrumentation system for observability, monitoring, and logging.
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
- ## Quick Setup
5
+ ## Event names
6
6
 
7
- Enable instrumentation in your Rails application:
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
- ```ruby
10
- # config/initializers/flowchat.rb
11
- FlowChat.setup_instrumentation!
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
- This sets up:
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
- ```ruby
78
- # Get current metrics snapshot
79
- metrics = FlowChat.metrics.snapshot
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
- ### Custom Instrumentation in Flows
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
- class PaymentFlow < FlowChat::Flow
96
- def process_payment
97
- # Instrument custom events in your flows
98
- instrument("payment.started", {
99
- amount: payment_amount,
100
- currency: "USD",
101
- payment_method: "mobile_money"
102
- }) do
103
- # Payment processing logic
104
- result = process_mobile_money_payment
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
- ### Event Subscribers
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
- # config/initializers/flowchat_instrumentation.rb
117
- FlowChat.setup_instrumentation!
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
- # Subscribe to all FlowChat events
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
- ### Integration with Monitoring Services
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
- # config/initializers/flowchat_monitoring.rb
138
- FlowChat.setup_instrumentation!
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
- # Track error rates
148
- ActiveSupport::Notifications.subscribe("flow.execution.error.flow_chat") do |event|
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
- ## Performance Impact
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
- The instrumentation system is designed for production use with minimal overhead:
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
- - **Event Publishing**: ~0.1ms per event
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
- ## Debugging & Troubleshooting
108
+ ## Subscribing
164
109
 
165
- ### Enable Debug Logging
110
+ Subscribe with `ActiveSupport::Notifications`, remembering the `.flow_chat` suffix:
166
111
 
167
112
  ```ruby
168
- # config/environments/development.rb
169
- config.log_level = :debug
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
- ```ruby
175
- # Clear all metrics (useful for testing)
176
- FlowChat.metrics.reset!
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
- ### Check Event Subscribers
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
- # See all active subscribers
183
- ActiveSupport::Notifications.notifier.listeners_for("flow.execution.end.flow_chat")
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
- ## Testing Instrumentation
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
- ```ruby
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
- # In your tests
198
- class FlowInstrumentationTest < ActiveSupport::TestCase
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)