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.
Files changed (103) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +43 -0
  3. data/CHANGELOG.md +26 -0
  4. data/README.md +166 -295
  5. data/Rakefile +12 -1
  6. data/SECURITY.md +1 -1
  7. data/docs/architecture.md +52 -479
  8. data/docs/async-background-processing.md +31 -265
  9. data/docs/configuration.md +106 -613
  10. data/docs/factory-pattern.md +27 -324
  11. data/docs/gateway-context-variables.md +140 -143
  12. data/docs/gateway-development.md +86 -650
  13. data/docs/getting-started.md +40 -379
  14. data/docs/instrumentation.md +88 -279
  15. data/docs/platforms/instagram.md +278 -0
  16. data/docs/platforms/messenger.md +205 -0
  17. data/docs/platforms/telegram.md +47 -951
  18. data/docs/platforms/ussd.md +38 -653
  19. data/docs/platforms/whatsapp.md +73 -1321
  20. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md +732 -0
  21. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json +58 -0
  22. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md +4064 -0
  23. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json +226 -0
  24. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md +972 -0
  25. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json +88 -0
  26. data/docs/superpowers/specs/2026-07-09-inbound-media-support-design.md +195 -0
  27. data/docs/superpowers/specs/2026-08-10-messenger-instagram-design.md +391 -0
  28. data/docs/testing.md +26 -297
  29. data/examples/http_controller.rb +12 -10
  30. data/examples/intercom_configuration_example.rb +19 -24
  31. data/examples/intercom_controller.rb +8 -20
  32. data/examples/multi_tenant_whatsapp_controller.rb +61 -166
  33. data/examples/simulator_controller.rb +0 -1
  34. data/examples/ussd_controller.rb +86 -158
  35. data/examples/whatsapp_controller.rb +16 -15
  36. data/examples/whatsapp_media_examples.rb +27 -79
  37. data/lib/flow_chat/app.rb +129 -11
  38. data/lib/flow_chat/choice_titles.rb +95 -0
  39. data/lib/flow_chat/config.rb +124 -1
  40. data/lib/flow_chat/delivery_error.rb +9 -0
  41. data/lib/flow_chat/http/configuration_error.rb +9 -0
  42. data/lib/flow_chat/http/gateway/simple.rb +24 -4
  43. data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
  44. data/lib/flow_chat/input.rb +86 -0
  45. data/lib/flow_chat/instagram/client.rb +32 -0
  46. data/lib/flow_chat/instagram/configuration.rb +147 -0
  47. data/lib/flow_chat/instagram/configuration_error.rb +7 -0
  48. data/lib/flow_chat/instagram/gateway/send_api.rb +63 -0
  49. data/lib/flow_chat/instagram/middleware/choice_mapper.rb +22 -0
  50. data/lib/flow_chat/instagram/renderer.rb +23 -0
  51. data/lib/flow_chat/instrumentation/metrics_collector.rb +6 -1
  52. data/lib/flow_chat/instrumentation.rb +160 -1
  53. data/lib/flow_chat/intercom/client.rb +34 -28
  54. data/lib/flow_chat/intercom/configuration.rb +2 -49
  55. data/lib/flow_chat/intercom/configuration_error.rb +9 -0
  56. data/lib/flow_chat/intercom/gateway/intercom_api.rb +81 -57
  57. data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
  58. data/lib/flow_chat/intercom/renderer.rb +57 -5
  59. data/lib/flow_chat/media.rb +121 -0
  60. data/lib/flow_chat/messenger/client.rb +264 -0
  61. data/lib/flow_chat/messenger/configuration.rb +103 -0
  62. data/lib/flow_chat/messenger/configuration_error.rb +9 -0
  63. data/lib/flow_chat/messenger/gateway/send_api.rb +42 -0
  64. data/lib/flow_chat/messenger/middleware/choice_mapper.rb +185 -0
  65. data/lib/flow_chat/messenger/renderer.rb +150 -0
  66. data/lib/flow_chat/meta/challenge.rb +24 -0
  67. data/lib/flow_chat/meta/choice_ladder.rb +37 -0
  68. data/lib/flow_chat/meta/configuration_error.rb +7 -0
  69. data/lib/flow_chat/meta/gateway_identity.rb +38 -0
  70. data/lib/flow_chat/meta/messaging_gateway.rb +468 -0
  71. data/lib/flow_chat/meta/signature.rb +30 -0
  72. data/lib/flow_chat/meta/signature_validation.rb +66 -0
  73. data/lib/flow_chat/meta/webhook_verification.rb +43 -0
  74. data/lib/flow_chat/named_configuration.rb +65 -0
  75. data/lib/flow_chat/prompt.rb +13 -16
  76. data/lib/flow_chat/renderers/markdown_support.rb +109 -0
  77. data/lib/flow_chat/security.rb +76 -0
  78. data/lib/flow_chat/session/middleware.rb +11 -2
  79. data/lib/flow_chat/simulator/controller.rb +31 -15
  80. data/lib/flow_chat/simulator/views/simulator.html.erb +184 -20
  81. data/lib/flow_chat/telegram/client.rb +47 -4
  82. data/lib/flow_chat/telegram/configuration.rb +2 -42
  83. data/lib/flow_chat/telegram/configuration_error.rb +9 -0
  84. data/lib/flow_chat/telegram/gateway/bot_api.rb +46 -28
  85. data/lib/flow_chat/telegram/middleware/choice_mapper.rb +77 -16
  86. data/lib/flow_chat/telegram/renderer.rb +10 -2
  87. data/lib/flow_chat/text_truncator.rb +75 -0
  88. data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
  89. data/lib/flow_chat/version.rb +1 -1
  90. data/lib/flow_chat/whatsapp/client.rb +14 -7
  91. data/lib/flow_chat/whatsapp/configuration.rb +12 -51
  92. data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
  93. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +254 -203
  94. data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +144 -57
  95. data/lib/flow_chat/whatsapp/renderer.rb +121 -60
  96. data/lib/flow_chat.rb +0 -10
  97. data/site/.nojekyll +0 -0
  98. data/site/.og-card.html +89 -0
  99. data/site/favicon.svg +6 -0
  100. data/site/index.html +209 -0
  101. data/site/og.png +0 -0
  102. metadata +51 -3
  103. data/lib/flow_chat/whatsapp/id_generator.rb +0 -124
@@ -1,298 +1,64 @@
1
- # Async Background Processing
1
+ # Async and background processing
2
2
 
3
- FlowChat supports asynchronous background processing to decouple flow execution from webhook request-response cycles. Webhook endpoints respond immediately (< 100ms) while flows process in background jobs.
3
+ A webhook has a response deadline. If a flow does slow work (calling the platform API, hitting your own services) the webhook can time out and the platform will retry, sometimes delivering the same message twice. Async processing acknowledges the webhook immediately and runs the flow in a background job.
4
4
 
5
- ## Quick Start
5
+ This works on gateways with an outbound API (WhatsApp, Telegram, HTTP, Intercom), because the job can send the reply through that API afterward. USSD cannot use it: its protocol requires the answer in the webhook response itself.
6
6
 
7
- ### Option 1: Using Factory Pattern (Recommended)
7
+ ## Enabling it
8
8
 
9
- The cleanest approach uses the Factory pattern for centralized configuration:
9
+ Call `use_async` in the processor. It has two forms.
10
10
 
11
- ```ruby
12
- # config/initializers/flow_chat.rb
13
- FlowChat::Factory.register :whatsapp do |controller|
14
- processor = FlowChat::Processor.new(controller) do |config|
15
- config.use_gateway FlowChat::Whatsapp::Gateway::CloudApi
16
- config.use_session_store FlowChat::Session::CacheSessionStore
17
- config.use_session_config(boundaries: [:flow])
18
- config.use_async(factory: :whatsapp) # Self-referencing for async
19
- end
20
- processor.run(WhatsAppFlow, :start)
21
- end
22
-
23
- # app/controllers/webhooks_controller.rb
24
- class WebhooksController < ApplicationController
25
- def whatsapp
26
- FlowChat::Factory.execute(:whatsapp, controller: self)
27
- end
28
- end
29
- ```
30
-
31
- **Benefits:**
32
- - No custom job class needed
33
- - Configuration defined once in initializer
34
- - Works seamlessly in both webhook and background contexts
35
-
36
- See [Factory Pattern Documentation](factory-pattern.md) for more details.
37
-
38
- ### Option 2: Custom Job Class
39
-
40
- For advanced use cases, create a custom job class:
11
+ Use a factory, and let FlowChat provide the job class:
41
12
 
42
13
  ```ruby
43
- # app/jobs/my_flow_job.rb
44
- class MyFlowJob < FlowChat::AsyncJob
45
- def execute(controller, **job_params)
46
- # Access job params passed from use_async
47
- deployment_id = job_params[:deployment_id]
48
-
49
- # Build and run your processor
50
- processor = FlowChat::Processor.new(controller) do |config|
51
- config.use_gateway FlowChat::Whatsapp::Gateway::CloudApi
52
- config.use_session_store FlowChat::Session::CacheSessionStore
53
- config.use_session_config(boundaries: [:flow])
54
- end
55
-
56
- processor.run MyFlow, :start
57
- end
58
- end
59
-
60
- # app/controllers/webhooks_controller.rb
61
- class WebhooksController < ApplicationController
62
- def whatsapp
63
- processor = FlowChat::Processor.new(self) do |config|
64
- config.use_gateway FlowChat::Whatsapp::Gateway::CloudApi
65
- config.use_session_store FlowChat::Session::CacheSessionStore
66
- config.use_async MyFlowJob, deployment_id: params[:deployment_id]
67
- end
68
- processor.run(MyFlow, :start)
69
- end
70
- end
14
+ config.use_async(factory: :whatsapp)
71
15
  ```
72
16
 
73
- ## Configuration
74
-
75
- ### Session Store
76
-
77
- Use a centralized cache store e.g. `CacheSessionStore` for async processing:
17
+ Or use your own ActiveJob subclass, with optional job params:
78
18
 
79
19
  ```ruby
80
- config.use_session_store FlowChat::Session::CacheSessionStore
20
+ config.use_async(MyFlowJob, deployment_id: 123)
81
21
  ```
82
22
 
83
- ### ActiveJob Backend
23
+ The `factory:` form is the common one and needs no custom job class. See [factory-pattern.md](factory-pattern.md).
84
24
 
85
- ```ruby
86
- # config/application.rb
87
- config.active_job.queue_adapter = :sidekiq # or :resque, :delayed_job
88
- ```
25
+ ## How it works
89
26
 
90
- ### Queue Configuration
27
+ The `GatewayAsyncSupport` concern, mixed into every async-capable gateway, does the detection and enqueueing.
91
28
 
92
- Set queue in job class:
29
+ 1. On a real webhook, the gateway calls `should_enqueue_async?`. It returns true when async is enabled, the gateway supports it (`async_supported?`), and the request is not already running in the background.
30
+ 2. If so, the gateway serializes the request (params, method, headers, host, path, body, remote ip) and enqueues the job with `perform_later(request_context: ..., **job_params)`, then returns an immediate acknowledgement to the platform.
31
+ 3. The job reconstructs a `FlowChat::BackgroundController` from the serialized request. It quacks like a Rails controller (its `render` and `head` are no-ops, its `request` is a `BackgroundRequest` rebuilt from the serialized data), so the same gateway code runs against it.
32
+ 4. Running in the background, `should_enqueue_async?` now returns false (the controller is a `BackgroundController`), so the gateway processes the flow inline and sends the reply through the platform API.
93
33
 
94
- ```ruby
95
- class MyFlowJob < FlowChat::AsyncJob
96
- queue_as :support_flows
34
+ The same gateway and the same flow run in both passes. The only difference is who calls them: the webhook the first time, the job the second.
97
35
 
98
- def execute(controller)
99
- # ...
100
- end
101
- end
102
- ```
36
+ ## Custom jobs
103
37
 
104
- Configure priorities:
38
+ `FlowChat::AsyncJob` is the base class. It handles reconstructing the controller in `perform`; you implement `execute(controller, **job_params)`:
105
39
 
106
40
  ```ruby
107
- # config/sidekiq.yml
108
- :queues:
109
- - [critical, 2]
110
- - [support_flows, 1]
111
- - [default, 1]
112
- ```
113
-
114
- ## Passing Parameters to Jobs
115
-
116
- ### Job Params
117
-
118
- When using custom job classes, you can pass additional parameters via `use_async`:
119
-
120
- ```ruby
121
- # In controller
122
- config.use_async(MyFlowJob, deployment_id: 123, flow_name: "SupportFlow")
123
-
124
- # In job execute method
125
41
  class MyFlowJob < FlowChat::AsyncJob
126
42
  def execute(controller, **job_params)
127
- deployment_id = job_params[:deployment_id] # => 123
128
- flow_name = job_params[:flow_name] # => "SupportFlow"
129
-
130
- # Use params for business logic
43
+ deployment_id = job_params[:deployment_id]
131
44
  processor = FlowChat::Processor.new(controller) do |config|
132
- # ... configuration
133
- end
134
- processor.run(MyFlow, :start)
135
- end
136
- end
137
- ```
138
-
139
- **Note:** Request params (from `controller.params`) are automatically available in the background job via the reconstructed `controller.params`. Job params are for additional data specific to job execution logic.
140
-
141
- ### Factory with GenericAsyncJob
142
-
143
- When using `factory:` param, additional params are ignored by `GenericAsyncJob` but can be used by the factory:
144
-
145
- ```ruby
146
- config.use_async(factory: :whatsapp, extra_data: "value")
147
- # extra_data is passed to perform but not used by GenericAsyncJob
148
- ```
149
-
150
- ## Gateway Support
151
-
152
- | Gateway | Async Support | Nil Response Handling |
153
- |---------|--------------|----------------------|
154
- | WhatsApp Cloud API | ✅ | ✅ Returns silently |
155
- | Intercom API | ✅ | ✅ Returns silently |
156
- | HTTP Simple | ✅ | ✅ Returns `{type: :skip}` JSON |
157
- | USSD Nalo | ❌ (synchronous protocol) | ❌ Requires immediate response |
158
-
159
- ### Middleware Nil Response Handling
160
-
161
- Async-capable gateways support middleware that returns `nil` instead of a response tuple. This is useful for middleware that handles responses directly (e.g., `AgentHandoffMiddleware`):
162
-
163
- ```ruby
164
- class AgentHandoffMiddleware
165
- def call(context)
166
- if should_handoff_to_agent?
167
- # Middleware handles response directly
168
- send_to_agent(context)
169
- return nil # Signal that response was handled
45
+ config.use_gateway FlowChat::Whatsapp::Gateway::CloudApi
46
+ config.use_session_store FlowChat::Session::CacheSessionStore
47
+ config.use_async(MyFlowJob, deployment_id: deployment_id)
170
48
  end
171
-
172
- @app.call(context) # Continue to next middleware
49
+ processor.run(WhatsAppFlow, :start)
173
50
  end
174
51
  end
175
52
  ```
176
53
 
177
- When middleware returns `nil`:
178
- - **HTTP Simple**: Returns JSON response `{type: :skip, session_id, user_id, timestamp}`
179
- - **WhatsApp/Intercom**: Returns silently (message already sent by middleware)
180
- - **USSD**: Not supported - synchronous protocol requires immediate response
181
-
182
- **Note**: USSD cannot support middleware that returns nil because the USSD protocol requires an immediate synchronous response to every request.
183
-
184
- ## How It Works
185
-
186
- ### Execution Paths
187
-
188
- FlowChat's async system automatically routes requests through one of three paths:
189
-
190
- #### 1. Webhook → Async Job (async enabled, webhook context)
191
-
192
- ```
193
- 1. Webhook request arrives at controller
194
- 2. Processor.run() called with use_async configured
195
- 3. Gateway checks: should_enqueue_async?
196
- - Not in background? ✅
197
- - Async enabled? ✅
198
- - Gateway supports async? ✅
199
- 4. Gateway serializes request context:
200
- - params (session_id, user_id, input, etc.)
201
- - method (POST/GET)
202
- - headers (Content-Type, User-Agent)
203
- 5. Gateway enqueues job: JobClass.perform_later(request_context: {...})
204
- 6. Gateway returns immediately (< 100ms)
205
- 7. Background worker picks up job
206
- 8. AsyncJob.perform creates BackgroundController from request_context
207
- 9. User's execute() called with controller
208
- 10. Flow processes normally with reconstructed request
209
- ```
210
-
211
- #### 2. Background Job → Inline (async enabled, background context)
212
-
213
- ```
214
- 1. Background job executes
215
- 2. AsyncJob.perform creates BackgroundController
216
- 3. User's execute() creates new processor
217
- 4. Processor.run() called
218
- 5. Gateway checks: should_enqueue_async?
219
- - Not in background? ❌ (BackgroundController detected)
220
- - Async enabled? ✅
221
- - Gateway supports async? ✅
222
- 6. Gateway processes inline (prevents double-enqueueing)
223
- 7. Flow executes, calls controller.render (no-op in background)
224
- 8. Job completes
225
- ```
226
-
227
- #### 3. Webhook → Inline (async not configured)
228
-
229
- ```
230
- 1. Webhook request arrives
231
- 2. Processor.run() called without use_async
232
- 3. Gateway checks: should_enqueue_async?
233
- - Not in background? ✅
234
- - Async enabled? ❌
235
- - Gateway supports async? ✅
236
- 4. Gateway processes inline
237
- 5. Flow executes
238
- 6. Response returned to webhook provider
239
- ```
240
-
241
- ### Key Components
54
+ The job params you pass to `use_async` are forwarded to `execute`, so the background pass can rebuild the same processor. `FlowChat::GenericAsyncJob` is exactly this pattern wrapped around a factory, which is why the `factory:` form needs no job class of your own.
242
55
 
243
- **AsyncJob**
244
- - Base class users inherit from
245
- - `perform(request_context:, **job_params)` entry point
246
- - Creates `BackgroundController` from serialized request
247
- - Calls user's `execute(controller, **job_params)` method
248
-
249
- **GenericAsyncJob**
250
- - Built-in job that uses Factory pattern
251
- - Automatically used when `use_async(factory: :name)` is called
252
- - Executes registered factory in background context
253
- - Validates factory is registered before execution
254
-
255
- **BackgroundController**
256
- - Duck-types as Rails controller
257
- - Provides `request`, `params`, `render`, `head` interface
258
- - `params` delegates to `request.params` (mimics Rails controller)
259
- - `render` and `head` are no-ops in background context
260
- - `is_a?(FlowChat::BackgroundController)` returns true for detection
261
-
262
- **BackgroundRequest**
263
- - Reconstructs full Rails request interface from serialized data
264
- - Provides `params`, `method`, `headers`, `host`, `path`, `remote_ip` accessors
265
- - HTTP method predicates: `post?`, `get?`, `head?`
266
- - Rails compatibility methods: `request_method`, `user_agent`, `ssl?`
267
- - Request body support: `body` returns object with `read()` and `rewind()` methods
268
- - Body content is serialized from webhook and reconstructed in background
269
- - Returns empty hash for `cookies` (not available in background context)
270
-
271
- **GatewayAsyncSupport**
272
- - Concern mixed into all gateways
273
- - `should_enqueue_async?` decision logic
274
- - `in_background?` detection via `BackgroundController`
275
- - `enqueue_async_job` serializes and enqueues
276
- - `async_supported?` override for gateways like USSD
277
-
278
- ### Decision Logic
279
-
280
- The gateway's `should_enqueue_async?` method checks three conditions:
281
-
282
- ```ruby
283
- def should_enqueue_async?
284
- processor = @context["processor"]
285
-
286
- !in_background? && # Prevent double-enqueueing
287
- processor&.async_enabled? && # User opted in
288
- async_supported? # Gateway allows it
289
- end
290
- ```
56
+ ## ActiveJob is optional
291
57
 
292
- **All three must be true** to enqueue. If any condition is false, the flow processes inline.
58
+ `FlowChat::AsyncJob` subclasses `ActiveJob::Base` when ActiveJob is available. Without ActiveJob, FlowChat falls back to a plain class, so requiring the gem does not fail; you supply the queueing yourself in that case.
293
59
 
294
- This ensures:
295
- - Background jobs never enqueue another job
296
- - Users explicitly opt in per processor
297
- - Synchronous protocols (USSD) always process inline
60
+ ## Related
298
61
 
62
+ - [Factory pattern](factory-pattern.md)
63
+ - [Configuration](configuration.md#async-processing)
64
+ - [Architecture](architecture.md)