flow_chat 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/pages.yml +43 -0
- data/CHANGELOG.md +26 -0
- data/README.md +166 -295
- data/Rakefile +12 -1
- data/SECURITY.md +1 -1
- data/docs/architecture.md +52 -479
- data/docs/async-background-processing.md +31 -265
- data/docs/configuration.md +106 -613
- data/docs/factory-pattern.md +27 -324
- data/docs/gateway-context-variables.md +140 -143
- data/docs/gateway-development.md +86 -650
- data/docs/getting-started.md +40 -379
- data/docs/instrumentation.md +88 -279
- data/docs/platforms/instagram.md +278 -0
- data/docs/platforms/messenger.md +205 -0
- data/docs/platforms/telegram.md +47 -951
- data/docs/platforms/ussd.md +38 -653
- data/docs/platforms/whatsapp.md +73 -1321
- data/docs/superpowers/plans/2026-07-09-inbound-media-support.md +732 -0
- data/docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json +58 -0
- data/docs/superpowers/plans/2026-08-10-messenger-instagram.md +4064 -0
- data/docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json +226 -0
- data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md +972 -0
- data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json +88 -0
- data/docs/superpowers/specs/2026-07-09-inbound-media-support-design.md +195 -0
- data/docs/superpowers/specs/2026-08-10-messenger-instagram-design.md +391 -0
- data/docs/testing.md +26 -297
- data/examples/http_controller.rb +12 -10
- data/examples/intercom_configuration_example.rb +19 -24
- data/examples/intercom_controller.rb +8 -20
- data/examples/multi_tenant_whatsapp_controller.rb +61 -166
- data/examples/simulator_controller.rb +0 -1
- data/examples/ussd_controller.rb +86 -158
- data/examples/whatsapp_controller.rb +16 -15
- data/examples/whatsapp_media_examples.rb +27 -79
- data/lib/flow_chat/app.rb +129 -11
- data/lib/flow_chat/choice_titles.rb +95 -0
- data/lib/flow_chat/config.rb +124 -1
- data/lib/flow_chat/delivery_error.rb +9 -0
- data/lib/flow_chat/http/configuration_error.rb +9 -0
- data/lib/flow_chat/http/gateway/simple.rb +24 -4
- data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
- data/lib/flow_chat/input.rb +86 -0
- data/lib/flow_chat/instagram/client.rb +32 -0
- data/lib/flow_chat/instagram/configuration.rb +147 -0
- data/lib/flow_chat/instagram/configuration_error.rb +7 -0
- data/lib/flow_chat/instagram/gateway/send_api.rb +63 -0
- data/lib/flow_chat/instagram/middleware/choice_mapper.rb +22 -0
- data/lib/flow_chat/instagram/renderer.rb +23 -0
- data/lib/flow_chat/instrumentation/metrics_collector.rb +6 -1
- data/lib/flow_chat/instrumentation.rb +160 -1
- data/lib/flow_chat/intercom/client.rb +34 -28
- data/lib/flow_chat/intercom/configuration.rb +2 -49
- data/lib/flow_chat/intercom/configuration_error.rb +9 -0
- data/lib/flow_chat/intercom/gateway/intercom_api.rb +81 -57
- data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
- data/lib/flow_chat/intercom/renderer.rb +57 -5
- data/lib/flow_chat/media.rb +121 -0
- data/lib/flow_chat/messenger/client.rb +264 -0
- data/lib/flow_chat/messenger/configuration.rb +103 -0
- data/lib/flow_chat/messenger/configuration_error.rb +9 -0
- data/lib/flow_chat/messenger/gateway/send_api.rb +42 -0
- data/lib/flow_chat/messenger/middleware/choice_mapper.rb +185 -0
- data/lib/flow_chat/messenger/renderer.rb +150 -0
- data/lib/flow_chat/meta/challenge.rb +24 -0
- data/lib/flow_chat/meta/choice_ladder.rb +37 -0
- data/lib/flow_chat/meta/configuration_error.rb +7 -0
- data/lib/flow_chat/meta/gateway_identity.rb +38 -0
- data/lib/flow_chat/meta/messaging_gateway.rb +468 -0
- data/lib/flow_chat/meta/signature.rb +30 -0
- data/lib/flow_chat/meta/signature_validation.rb +66 -0
- data/lib/flow_chat/meta/webhook_verification.rb +43 -0
- data/lib/flow_chat/named_configuration.rb +65 -0
- data/lib/flow_chat/prompt.rb +13 -16
- data/lib/flow_chat/renderers/markdown_support.rb +109 -0
- data/lib/flow_chat/security.rb +76 -0
- data/lib/flow_chat/session/middleware.rb +11 -2
- data/lib/flow_chat/simulator/controller.rb +31 -15
- data/lib/flow_chat/simulator/views/simulator.html.erb +184 -20
- data/lib/flow_chat/telegram/client.rb +47 -4
- data/lib/flow_chat/telegram/configuration.rb +2 -42
- data/lib/flow_chat/telegram/configuration_error.rb +9 -0
- data/lib/flow_chat/telegram/gateway/bot_api.rb +46 -28
- data/lib/flow_chat/telegram/middleware/choice_mapper.rb +77 -16
- data/lib/flow_chat/telegram/renderer.rb +10 -2
- data/lib/flow_chat/text_truncator.rb +75 -0
- data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
- data/lib/flow_chat/version.rb +1 -1
- data/lib/flow_chat/whatsapp/client.rb +14 -7
- data/lib/flow_chat/whatsapp/configuration.rb +12 -51
- data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
- data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +254 -203
- data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +144 -57
- data/lib/flow_chat/whatsapp/renderer.rb +121 -60
- data/lib/flow_chat.rb +0 -10
- data/site/.nojekyll +0 -0
- data/site/.og-card.html +89 -0
- data/site/favicon.svg +6 -0
- data/site/index.html +209 -0
- data/site/og.png +0 -0
- metadata +51 -3
- data/lib/flow_chat/whatsapp/id_generator.rb +0 -124
data/docs/architecture.md
CHANGED
|
@@ -1,510 +1,83 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Architecture
|
|
2
2
|
|
|
3
|
-
FlowChat
|
|
3
|
+
FlowChat turns a stateless webhook into a stateful conversation by re-running your flow method from the top on every request and replaying its answers out of a session. This document explains the pieces that make that work.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## The middleware stack
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
A `FlowChat::Processor` builds one middleware stack per request, in a fixed order. Each layer wraps the next and calls it:
|
|
8
8
|
|
|
9
|
-
FlowChat abstracts platform differences through a unified API, allowing the same flow code to work across USSD, WhatsApp, Telegram, HTTP, and custom platforms.
|
|
10
|
-
|
|
11
|
-
```ruby
|
|
12
|
-
# This exact code works on ALL platforms
|
|
13
|
-
def survey_flow
|
|
14
|
-
name = app.screen(:name) { |p| p.ask "What's your name?" }
|
|
15
|
-
rating = app.screen(:rating) { |p| p.select "Rate us:", ["1", "2", "3", "4", "5"] }
|
|
16
|
-
app.say "Thanks #{name}! You rated us #{rating}/5"
|
|
17
|
-
end
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
### 2. Composition Over Inheritance
|
|
21
|
-
|
|
22
|
-
Rather than platform-specific classes, FlowChat uses composition to configure behavior:
|
|
23
|
-
|
|
24
|
-
```ruby
|
|
25
|
-
# Old approach (inheritance) - DEPRECATED
|
|
26
|
-
class UssdApp < FlowChat::BaseApp; end
|
|
27
|
-
class WhatsappApp < FlowChat::BaseApp; end
|
|
28
|
-
|
|
29
|
-
# New approach (composition) - CURRENT
|
|
30
|
-
processor = FlowChat::Processor.new(self) do |config|
|
|
31
|
-
config.use_gateway FlowChat::Ussd::Gateway::Nalo # Platform behavior
|
|
32
|
-
config.use_session_store FlowChat::Session::CacheSessionStore # Session behavior
|
|
33
|
-
config.use_middleware CustomLoggingMiddleware # Custom behavior
|
|
34
|
-
end
|
|
35
9
|
```
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
```ruby
|
|
42
|
-
# Built-in USSD gateway
|
|
43
|
-
config.use_gateway FlowChat::Ussd::Gateway::Nalo
|
|
44
|
-
|
|
45
|
-
# Example custom USSD gateways (you would build these)
|
|
46
|
-
config.use_gateway YourCompany::Ussd::Gateway::Africaist
|
|
47
|
-
config.use_gateway YourCompany::Ussd::Gateway::MTN
|
|
48
|
-
|
|
49
|
-
# Built-in WhatsApp gateway
|
|
50
|
-
config.use_gateway FlowChat::Whatsapp::Gateway::CloudApi
|
|
51
|
-
|
|
52
|
-
# Example custom WhatsApp gateways (you would build these)
|
|
53
|
-
config.use_gateway YourCompany::Whatsapp::Gateway::OnPremise
|
|
54
|
-
config.use_gateway YourCompany::Whatsapp::Gateway::Twilio
|
|
55
|
-
|
|
56
|
-
# Example custom platforms (you would build these)
|
|
57
|
-
config.use_gateway YourCompany::Sms::Gateway::Twilio
|
|
58
|
-
config.use_gateway YourCompany::Voice::Gateway::Plivo
|
|
59
|
-
config.use_gateway YourCompany::Slack::Gateway::BoltJS
|
|
10
|
+
Gateway
|
|
11
|
+
-> Session::Middleware
|
|
12
|
+
-> platform middleware (and your custom middleware)
|
|
13
|
+
-> Executor
|
|
14
|
+
-> your Flow
|
|
60
15
|
```
|
|
61
16
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
### Processor
|
|
65
|
-
|
|
66
|
-
The `FlowChat::Processor` is the central orchestrator that builds and executes the middleware stack.
|
|
67
|
-
|
|
68
|
-
```ruby
|
|
69
|
-
processor = FlowChat::Processor.new(controller) do |config|
|
|
70
|
-
config.use_gateway GatewayClass, *gateway_args
|
|
71
|
-
config.use_session_store SessionStoreClass
|
|
72
|
-
config.use_middleware CustomMiddleware
|
|
73
|
-
config.use_session_config(boundaries: [:flow], identifier: :msisdn)
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
processor.run FlowClass, :action_method
|
|
77
|
-
```
|
|
17
|
+
The order is set in `Processor#create_middleware_stack`:
|
|
78
18
|
|
|
79
|
-
**
|
|
80
|
-
|
|
81
|
-
-
|
|
82
|
-
|
|
83
|
-
- Handle errors and instrumentation
|
|
19
|
+
1. **Gateway** runs first. It parses the platform's webhook into normalized context values (`request.msisdn`, `context.input`, and so on) and, after the inner stack returns, renders the result back to the platform. See [gateway-development.md](gateway-development.md).
|
|
20
|
+
2. **`Session::Middleware`** computes the session id from the configured boundaries and identifier, and attaches the session store. See [configuration.md](configuration.md#sessions).
|
|
21
|
+
3. **Platform middleware** is inserted by the gateway if it defines `configure_middleware_stack`. USSD uses this to add pagination and choice-number mapping, and it is where your custom middleware runs. A gateway without that hook just runs your custom middleware here.
|
|
22
|
+
4. **Executor** runs last. Nothing runs after it. It instantiates your flow and calls the action.
|
|
84
23
|
|
|
85
|
-
|
|
24
|
+
## The Executor and control flow by exception
|
|
86
25
|
|
|
87
|
-
|
|
26
|
+
`FlowChat::Executor` builds a `FlowChat::App`, instantiates your flow with it, and calls the action method. Control flow is driven by exceptions raised from inside the flow and caught here:
|
|
88
27
|
|
|
89
28
|
```ruby
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
context.input = extract_user_input(request)
|
|
101
|
-
|
|
102
|
-
# 2. Process through middleware stack
|
|
103
|
-
type, prompt, choices, media = @app.call(context)
|
|
104
|
-
|
|
105
|
-
# 3. Render platform-specific response
|
|
106
|
-
send_platform_response(type, prompt, choices, media)
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
# Optional: Configure platform-specific middleware
|
|
110
|
-
def self.configure_middleware_stack(builder, custom_middleware)
|
|
111
|
-
builder.use MyPlatform::SpecialMiddleware
|
|
112
|
-
builder.use custom_middleware
|
|
113
|
-
builder.use MyPlatform::ResponseMiddleware
|
|
114
|
-
end
|
|
115
|
-
end
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
**Gateway Interface:**
|
|
119
|
-
- `initialize(app, *args)` - Set up gateway with app and config
|
|
120
|
-
- `call(context)` - Process request through middleware stack
|
|
121
|
-
- `self.configure_middleware_stack(builder, custom_middleware)` - Optional middleware configuration
|
|
122
|
-
|
|
123
|
-
### App
|
|
124
|
-
|
|
125
|
-
The `FlowChat::App` provides the unified interface that flows use to interact with users.
|
|
126
|
-
|
|
127
|
-
```ruby
|
|
128
|
-
class FlowChat::App
|
|
129
|
-
def screen(key, &block)
|
|
130
|
-
# Automatic state management and navigation
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
def say(message, media: nil)
|
|
134
|
-
# Platform-appropriate message sending
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
# Context accessors that work across platforms
|
|
138
|
-
def msisdn, user_id, platform, gateway
|
|
139
|
-
def message_id, timestamp, contact_name, location, media
|
|
140
|
-
end
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
**Key Features:**
|
|
144
|
-
- **Screen-based navigation** with automatic state management
|
|
145
|
-
- **Platform-agnostic prompts** that adapt to each platform's capabilities
|
|
146
|
-
- **Consistent context** regardless of underlying platform
|
|
147
|
-
- **Session integration** for data persistence
|
|
148
|
-
|
|
149
|
-
### Session
|
|
150
|
-
|
|
151
|
-
Session management with configurable boundaries and storage backends.
|
|
152
|
-
|
|
153
|
-
```ruby
|
|
154
|
-
# Session boundaries control session ID generation
|
|
155
|
-
config.use_session_config(
|
|
156
|
-
boundaries: [:flow, :platform, :gateway, :url], # Session isolation
|
|
157
|
-
identifier: :msisdn, # Session key type
|
|
158
|
-
hash_identifiers: true # Privacy protection
|
|
159
|
-
)
|
|
160
|
-
|
|
161
|
-
# Session stores control where data is stored
|
|
162
|
-
config.use_session_store FlowChat::Session::CacheSessionStore # Rails sessions
|
|
163
|
-
config.use_session_store FlowChat::Session::CacheSessionStore # Rails cache
|
|
164
|
-
config.use_session_store YourCompany::Session::DatabaseStore # Custom store
|
|
165
|
-
```
|
|
166
|
-
|
|
167
|
-
### Middleware
|
|
168
|
-
|
|
169
|
-
Extensible processing pipeline for custom logic.
|
|
170
|
-
|
|
171
|
-
```ruby
|
|
172
|
-
class CustomMiddleware
|
|
173
|
-
def initialize(app)
|
|
174
|
-
@app = app
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
def call(context)
|
|
178
|
-
# Before processing
|
|
179
|
-
log_request(context)
|
|
180
|
-
|
|
181
|
-
# Process through stack
|
|
182
|
-
result = @app.call(context)
|
|
183
|
-
|
|
184
|
-
# After processing
|
|
185
|
-
log_response(context, result)
|
|
186
|
-
|
|
187
|
-
result
|
|
188
|
-
end
|
|
189
|
-
end
|
|
190
|
-
```
|
|
191
|
-
|
|
192
|
-
## Request Processing Flow
|
|
193
|
-
|
|
194
|
-
Here's how a request flows through FlowChat:
|
|
195
|
-
|
|
196
|
-
```mermaid
|
|
197
|
-
graph TD
|
|
198
|
-
A[Platform Request] --> B[Gateway]
|
|
199
|
-
B --> C[Parse Request]
|
|
200
|
-
C --> D[Set Context]
|
|
201
|
-
D --> E[Session Middleware]
|
|
202
|
-
E --> F[Custom Middleware]
|
|
203
|
-
F --> G[Executor]
|
|
204
|
-
G --> H[Flow/Action]
|
|
205
|
-
H --> I[App.screen]
|
|
206
|
-
I --> J[Prompt Processing]
|
|
207
|
-
J --> K[Response Generation]
|
|
208
|
-
K --> L[Gateway Response]
|
|
209
|
-
L --> M[Platform Response]
|
|
210
|
-
```
|
|
211
|
-
|
|
212
|
-
### Detailed Flow
|
|
213
|
-
|
|
214
|
-
1. **Request Arrives** at platform (USSD, WhatsApp, HTTP)
|
|
215
|
-
2. **Gateway** parses platform-specific request format
|
|
216
|
-
3. **Context Setup** with normalized request data
|
|
217
|
-
4. **Session Middleware** manages session state and boundaries
|
|
218
|
-
5. **Custom Middleware** processes business logic
|
|
219
|
-
6. **Executor** instantiates and calls flow
|
|
220
|
-
7. **Flow Method** uses `app.screen()` for conversation logic
|
|
221
|
-
8. **Prompt Processing** handles user input and validation
|
|
222
|
-
9. **Response Generation** creates platform-agnostic response
|
|
223
|
-
10. **Gateway Rendering** converts to platform-specific format
|
|
224
|
-
11. **Platform Response** sent back to user
|
|
225
|
-
|
|
226
|
-
## Middleware Stack
|
|
227
|
-
|
|
228
|
-
The middleware stack is built dynamically based on gateway capabilities:
|
|
229
|
-
|
|
230
|
-
```ruby
|
|
231
|
-
def create_middleware_stack
|
|
232
|
-
middleware_stack = ::Middleware::Builder.new(name: @gateway_class.name) do |b|
|
|
233
|
-
# Gateway always comes first
|
|
234
|
-
b.use @gateway_class, *@gateway_args
|
|
235
|
-
|
|
236
|
-
# Session middleware sets up session boundaries
|
|
237
|
-
b.use FlowChat::Session::Middleware, @session_options
|
|
238
|
-
|
|
239
|
-
# Platform-specific middleware (if gateway supports it)
|
|
240
|
-
if @gateway_class.respond_to?(:configure_middleware_stack)
|
|
241
|
-
@gateway_class.configure_middleware_stack(b, custom_middleware)
|
|
242
|
-
else
|
|
243
|
-
b.use custom_middleware
|
|
244
|
-
end
|
|
245
|
-
|
|
246
|
-
# Executor always goes last
|
|
247
|
-
b.use FlowChat::Executor
|
|
248
|
-
end
|
|
249
|
-
|
|
250
|
-
# Conditionally inject logger based on configuration
|
|
251
|
-
middleware_stack.inject_logger(FlowChat.logger) if FlowChat::Config.inject_middleware_logger
|
|
252
|
-
|
|
253
|
-
middleware_stack
|
|
254
|
-
end
|
|
29
|
+
flow = flow_class.new(app)
|
|
30
|
+
flow.send(action)
|
|
31
|
+
raise FlowChat::Interrupt::Terminate, "Unexpected end of flow."
|
|
32
|
+
rescue FlowChat::Interrupt::RestartFlow
|
|
33
|
+
retry
|
|
34
|
+
rescue FlowChat::Interrupt::Prompt => e
|
|
35
|
+
[:prompt, e.prompt, e.choices, e.media]
|
|
36
|
+
rescue FlowChat::Interrupt::Terminate => e
|
|
37
|
+
context.session.destroy
|
|
38
|
+
[:terminal, e.prompt, nil, e.media]
|
|
255
39
|
```
|
|
256
40
|
|
|
257
|
-
|
|
41
|
+
The three interrupts live in `FlowChat::Interrupt`:
|
|
258
42
|
|
|
259
|
-
|
|
43
|
+
| Interrupt | Raised by | Effect |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| `Prompt` | `prompt.ask` / `select` / `yes?` when no input is available | Unwinds to the Executor, which returns the prompt for the gateway to render. Carries the message, choices, and media. |
|
|
46
|
+
| `Terminate` | `app.say` and `prompt.say` | Ends the flow, destroys the session, and returns a terminal message. |
|
|
47
|
+
| `RestartFlow` | `app.go_back` | Caught with `retry`, which re-runs the flow from the top against a fresh `App`. |
|
|
260
48
|
|
|
261
|
-
|
|
262
|
-
class FlowChat::Ussd::Gateway::Nalo
|
|
263
|
-
def self.configure_middleware_stack(builder, custom_middleware)
|
|
264
|
-
builder.use FlowChat::Ussd::Middleware::Pagination
|
|
265
|
-
builder.use custom_middleware
|
|
266
|
-
builder.use FlowChat::Ussd::Middleware::ChoiceMapper
|
|
267
|
-
end
|
|
268
|
-
end
|
|
269
|
-
|
|
270
|
-
class FlowChat::Whatsapp::Gateway::CloudApi
|
|
271
|
-
def self.configure_middleware_stack(builder, custom_middleware)
|
|
272
|
-
builder.use FlowChat::Whatsapp::Middleware::MediaProcessor
|
|
273
|
-
builder.use custom_middleware
|
|
274
|
-
builder.use FlowChat::Whatsapp::Middleware::TemplateRenderer
|
|
275
|
-
end
|
|
276
|
-
end
|
|
277
|
-
```
|
|
278
|
-
|
|
279
|
-
## Session Architecture
|
|
280
|
-
|
|
281
|
-
### Session ID Generation
|
|
282
|
-
|
|
283
|
-
Session IDs are generated based on configurable boundaries:
|
|
284
|
-
|
|
285
|
-
```ruby
|
|
286
|
-
# Example session IDs based on boundaries
|
|
287
|
-
boundaries: [:flow] # => "survey_flow:user123"
|
|
288
|
-
boundaries: [:flow, :platform] # => "survey_flow:ussd:user123"
|
|
289
|
-
boundaries: [:flow, :platform, :gateway] # => "survey_flow:ussd:nalo:user123"
|
|
290
|
-
boundaries: [:flow, :url] # => "survey_flow:tenant1.app.com:user123"
|
|
291
|
-
```
|
|
292
|
-
|
|
293
|
-
### Session Stores
|
|
294
|
-
|
|
295
|
-
Different storage backends for different use cases:
|
|
296
|
-
|
|
297
|
-
```ruby
|
|
298
|
-
# Rails sessions (shorter-lived, good for testing)
|
|
299
|
-
FlowChat::Session::CacheSessionStore
|
|
300
|
-
|
|
301
|
-
# Rails cache (longer-lived, good for production)
|
|
302
|
-
FlowChat::Session::CacheSessionStore
|
|
303
|
-
|
|
304
|
-
# Custom database store (permanent storage)
|
|
305
|
-
class CustomDatabaseStore
|
|
306
|
-
def initialize(context)
|
|
307
|
-
@context = context
|
|
308
|
-
end
|
|
309
|
-
|
|
310
|
-
def get(key)
|
|
311
|
-
SessionData.find_by(session_id: @context["session.id"], key: key)&.value
|
|
312
|
-
end
|
|
313
|
-
|
|
314
|
-
def set(key, value)
|
|
315
|
-
SessionData.upsert({
|
|
316
|
-
session_id: @context["session.id"],
|
|
317
|
-
key: key,
|
|
318
|
-
value: value
|
|
319
|
-
})
|
|
320
|
-
end
|
|
321
|
-
end
|
|
322
|
-
```
|
|
323
|
-
|
|
324
|
-
## Extensibility Points
|
|
325
|
-
|
|
326
|
-
FlowChat provides multiple extension points:
|
|
327
|
-
|
|
328
|
-
### 1. Custom Gateways
|
|
329
|
-
|
|
330
|
-
Implement any platform by creating a gateway:
|
|
331
|
-
|
|
332
|
-
```ruby
|
|
333
|
-
class YourCompany::Telegram::Gateway::BotAPI
|
|
334
|
-
def initialize(app, bot_token)
|
|
335
|
-
@app = app
|
|
336
|
-
@bot_token = bot_token
|
|
337
|
-
end
|
|
338
|
-
|
|
339
|
-
def call(context)
|
|
340
|
-
# Parse Telegram webhook
|
|
341
|
-
update = JSON.parse(context.controller.request.body.read)
|
|
342
|
-
message = update.dig("message")
|
|
343
|
-
|
|
344
|
-
context["request.user_id"] = message["from"]["id"]
|
|
345
|
-
context["request.platform"] = :telegram
|
|
346
|
-
context.input = message["text"]
|
|
347
|
-
|
|
348
|
-
# Process through stack
|
|
349
|
-
type, prompt, choices, media = @app.call(context)
|
|
350
|
-
|
|
351
|
-
# Send response via Telegram API
|
|
352
|
-
send_telegram_message(prompt, to: context["request.user_id"])
|
|
353
|
-
end
|
|
354
|
-
end
|
|
355
|
-
```
|
|
356
|
-
|
|
357
|
-
### 2. Custom Middleware
|
|
358
|
-
|
|
359
|
-
Add processing logic at any point:
|
|
360
|
-
|
|
361
|
-
```ruby
|
|
362
|
-
class AuthenticationMiddleware
|
|
363
|
-
def call(context)
|
|
364
|
-
user = authenticate_user(context["request.user_id"])
|
|
365
|
-
context["current_user"] = user
|
|
366
|
-
@app.call(context)
|
|
367
|
-
end
|
|
368
|
-
end
|
|
369
|
-
|
|
370
|
-
class RateLimitingMiddleware
|
|
371
|
-
def call(context)
|
|
372
|
-
check_rate_limit(context["request.user_id"])
|
|
373
|
-
@app.call(context)
|
|
374
|
-
end
|
|
375
|
-
end
|
|
376
|
-
```
|
|
377
|
-
|
|
378
|
-
### 3. Custom Session Stores
|
|
379
|
-
|
|
380
|
-
Implement any storage backend:
|
|
381
|
-
|
|
382
|
-
```ruby
|
|
383
|
-
class RedisSessionStore
|
|
384
|
-
def get(key)
|
|
385
|
-
Redis.current.hget(session_key, key)
|
|
386
|
-
end
|
|
387
|
-
|
|
388
|
-
def set(key, value)
|
|
389
|
-
Redis.current.hset(session_key, key, value)
|
|
390
|
-
Redis.current.expire(session_key, 24.hours.to_i)
|
|
391
|
-
end
|
|
392
|
-
end
|
|
393
|
-
```
|
|
394
|
-
|
|
395
|
-
### 4. Custom Renderers
|
|
396
|
-
|
|
397
|
-
Platform-specific rendering logic:
|
|
398
|
-
|
|
399
|
-
```ruby
|
|
400
|
-
class MyPlatform::Renderer
|
|
401
|
-
def initialize(prompt, choices: nil, media: nil)
|
|
402
|
-
@prompt = prompt
|
|
403
|
-
@choices = choices
|
|
404
|
-
@media = media
|
|
405
|
-
end
|
|
406
|
-
|
|
407
|
-
def render
|
|
408
|
-
{
|
|
409
|
-
text: transform_text(@prompt),
|
|
410
|
-
buttons: transform_choices(@choices),
|
|
411
|
-
attachments: transform_media(@media)
|
|
412
|
-
}
|
|
413
|
-
end
|
|
414
|
-
end
|
|
415
|
-
```
|
|
416
|
-
|
|
417
|
-
## Configuration Architecture
|
|
418
|
-
|
|
419
|
-
FlowChat uses a flexible configuration system:
|
|
420
|
-
|
|
421
|
-
```ruby
|
|
422
|
-
# Global configuration
|
|
423
|
-
FlowChat::Config.logger = Rails.logger
|
|
424
|
-
FlowChat::Config.cache = Rails.cache
|
|
425
|
-
FlowChat::Config.inject_middleware_logger = true # Default: true in Rails development
|
|
426
|
-
|
|
427
|
-
# Platform-specific configuration
|
|
428
|
-
FlowChat::Config.ussd.pagination_page_size = 160
|
|
429
|
-
FlowChat::Config.whatsapp.message_handling_mode = :background
|
|
430
|
-
|
|
431
|
-
# Per-processor configuration
|
|
432
|
-
processor = FlowChat::Processor.new(self) do |config|
|
|
433
|
-
config.use_gateway GatewayClass, gateway_config
|
|
434
|
-
config.use_session_config(boundaries: [:flow], identifier: :user_id)
|
|
435
|
-
config.use_middleware CustomMiddleware
|
|
436
|
-
end
|
|
437
|
-
```
|
|
438
|
-
|
|
439
|
-
## Error Handling & Instrumentation
|
|
440
|
-
|
|
441
|
-
FlowChat includes comprehensive error handling and instrumentation:
|
|
442
|
-
|
|
443
|
-
```ruby
|
|
444
|
-
# Instrumentation events
|
|
445
|
-
FlowChat.instrument(Events::MESSAGE_RECEIVED, {
|
|
446
|
-
from: user_id,
|
|
447
|
-
message: input,
|
|
448
|
-
platform: :ussd
|
|
449
|
-
})
|
|
450
|
-
|
|
451
|
-
FlowChat.instrument(Events::FLOW_EXECUTION_START, {
|
|
452
|
-
flow_name: "survey_flow",
|
|
453
|
-
action: "start"
|
|
454
|
-
})
|
|
455
|
-
|
|
456
|
-
# Error handling in flows
|
|
457
|
-
def payment_flow
|
|
458
|
-
begin
|
|
459
|
-
process_payment(amount)
|
|
460
|
-
rescue PaymentError => e
|
|
461
|
-
app.say "Payment failed: #{e.message}"
|
|
462
|
-
FlowChat.instrument(Events::PAYMENT_FAILED, {
|
|
463
|
-
error: e.message,
|
|
464
|
-
user_id: app.user_id
|
|
465
|
-
})
|
|
466
|
-
end
|
|
467
|
-
end
|
|
468
|
-
```
|
|
49
|
+
If the action method returns normally without prompting or terminating, the Executor raises `Terminate` with "Unexpected end of flow", since a flow is expected to either prompt for more input or end with a message.
|
|
469
50
|
|
|
470
|
-
|
|
51
|
+
These interrupts subclass `Exception`, not `StandardError`. This is deliberate: a `rescue` in your flow code (a bare `rescue => e` catches `StandardError`) will not swallow a prompt or terminate and break the engine. Avoid `rescue Exception` inside a flow, since that would catch them.
|
|
471
52
|
|
|
472
|
-
|
|
53
|
+
## The replay model
|
|
473
54
|
|
|
474
|
-
-
|
|
475
|
-
- **Session isolation**: Users don't affect each other
|
|
476
|
-
- **Lazy loading**: Components loaded only when needed
|
|
55
|
+
There is no saved program counter between requests. Each webhook rebuilds the `App` and re-runs the flow method from the first line. Progress is reconstructed from the session:
|
|
477
56
|
|
|
478
|
-
|
|
57
|
+
- `app.screen(key)` checks the session for `key`. If a value is stored, it returns immediately without yielding the block. This fast-forwards through every screen already answered.
|
|
58
|
+
- The first screen without a stored answer yields a `Prompt`. If the current turn has input for it, the prompt validates and transforms it, stores the result with `session.set(key, value)`, and returns, so execution falls through to the next screen. If not, the prompt raises `Interrupt::Prompt` and the turn ends there.
|
|
479
59
|
|
|
480
|
-
|
|
481
|
-
- **Background processing**: Long-running tasks handled asynchronously
|
|
482
|
-
- **Caching**: Session data cached for performance
|
|
60
|
+
Two rules follow. One inbound message is consumed by one screen per turn: once a screen takes the input, later screens in the same run see no input and prompt. And a given screen key may be presented only once per run; re-entering a key raises `ArgumentError`. To revisit a screen, use `app.go_back`, which clears the current screen's answer and raises `RestartFlow`.
|
|
483
61
|
|
|
484
|
-
|
|
62
|
+
## The App
|
|
485
63
|
|
|
486
|
-
|
|
487
|
-
- **Compression**: Large responses automatically paginated (USSD)
|
|
488
|
-
- **Batching**: Multiple messages combined when possible (WhatsApp)
|
|
64
|
+
`FlowChat::App` is the single object your flow talks to. It wraps the context and exposes:
|
|
489
65
|
|
|
490
|
-
|
|
66
|
+
- `screen(key)`, the unit of interaction.
|
|
67
|
+
- `say(msg, media:)`, to end the flow.
|
|
68
|
+
- `go_back`, to return to the previous screen.
|
|
69
|
+
- Read accessors for the turn: `text`, `media`, `location`, `contact`, `attachment_type`, and identity values `msisdn`, `user_id`, `platform`, `gateway`, `message_id`, `timestamp`, `contact_name`.
|
|
491
70
|
|
|
492
|
-
|
|
71
|
+
`Flow` itself is a thin base class: it stores the `app` and nothing else. All conversation logic lives in the methods you write.
|
|
493
72
|
|
|
494
|
-
|
|
495
|
-
- **Session boundaries**: Prevent cross-tenant data access
|
|
496
|
-
- **Secure storage**: Session stores can encrypt data
|
|
73
|
+
## Sessions
|
|
497
74
|
|
|
498
|
-
|
|
75
|
+
The session is a key-value store keyed by the session id. `FlowChat::Session::CacheSessionStore` persists it in `FlowChat::Config.cache`, so a session outlives the request. `screen` reads and writes it, `Terminate` destroys it. Boundaries and identifiers, which decide what shares a session, are covered in [configuration.md](configuration.md#sessions).
|
|
499
76
|
|
|
500
|
-
|
|
501
|
-
- **Validation pipelines**: Custom validation in prompts
|
|
502
|
-
- **Type safety**: Input transformation prevents type errors
|
|
77
|
+
## Async
|
|
503
78
|
|
|
504
|
-
|
|
79
|
+
Gateways with an outbound API can run the flow in a background job instead of inline. The gateway detects async support, serializes the request, and enqueues a job that reconstructs a controller and re-runs the same stack in the background. USSD cannot do this, since its protocol needs a synchronous response. See [async-background-processing.md](async-background-processing.md) and [factory-pattern.md](factory-pattern.md).
|
|
505
80
|
|
|
506
|
-
|
|
507
|
-
- **Rate limiting**: Built-in protection against abuse
|
|
508
|
-
- **Access controls**: Gateway-level authentication
|
|
81
|
+
## Instrumentation
|
|
509
82
|
|
|
510
|
-
|
|
83
|
+
Each stage emits `ActiveSupport::Notifications` events (flow execution, messages received and sent, session lifecycle, pagination, webhook verification). Subscribe to feed metrics and logs into your own backend. See [instrumentation.md](instrumentation.md).
|