flow_chat 0.8.2 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.cliff.toml +74 -0
- data/.github/workflows/ci.yml +2 -3
- data/.github/workflows/pages.yml +43 -0
- data/.github/workflows/release.yml +56 -0
- data/.standard.yml +4 -0
- data/CHANGELOG.md +48 -0
- data/CLAUDE.md +327 -0
- data/CONTRIBUTING.md +134 -0
- data/Gemfile +1 -0
- data/README.md +189 -133
- data/Rakefile +17 -2
- data/SECURITY.md +42 -349
- data/docs/architecture.md +83 -0
- data/docs/async-background-processing.md +64 -0
- data/docs/configuration.md +110 -287
- data/docs/factory-pattern.md +58 -0
- data/docs/gateway-context-variables.md +168 -0
- data/docs/gateway-development.md +159 -0
- data/docs/getting-started.md +90 -0
- data/docs/instrumentation.md +95 -175
- data/docs/platforms/instagram.md +278 -0
- data/docs/platforms/messenger.md +205 -0
- data/docs/platforms/telegram.md +109 -0
- data/docs/platforms/ussd.md +78 -0
- data/docs/platforms/whatsapp.md +147 -0
- data/docs/superpowers/plans/2026-07-09-inbound-media-support.md +732 -0
- data/docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json +58 -0
- data/docs/superpowers/plans/2026-08-10-messenger-instagram.md +4064 -0
- data/docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json +226 -0
- data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md +972 -0
- data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json +88 -0
- data/docs/superpowers/specs/2026-07-09-inbound-media-support-design.md +195 -0
- data/docs/superpowers/specs/2026-08-10-messenger-instagram-design.md +391 -0
- data/docs/testing.md +33 -426
- data/examples/custom_session_id_example.rb +119 -0
- data/examples/http_controller.rb +22 -20
- data/examples/intercom_configuration_example.rb +113 -0
- data/examples/intercom_controller.rb +182 -0
- data/examples/multi_tenant_whatsapp_controller.rb +63 -168
- data/examples/simulator_controller.rb +0 -1
- data/examples/ussd_controller.rb +88 -160
- data/examples/whatsapp_controller.rb +18 -17
- data/examples/whatsapp_media_examples.rb +27 -79
- data/flow_chat.gemspec +4 -0
- data/lib/flow_chat/app.rb +211 -0
- data/lib/flow_chat/async_job.rb +176 -0
- data/lib/flow_chat/choice_titles.rb +95 -0
- data/lib/flow_chat/config.rb +126 -23
- data/lib/flow_chat/delivery_error.rb +9 -0
- data/lib/flow_chat/{base_executor.rb → executor.rb} +6 -11
- data/lib/flow_chat/factory.rb +94 -0
- data/lib/flow_chat/gateway_async_support.rb +106 -0
- data/lib/flow_chat/generic_async_job.rb +30 -0
- data/lib/flow_chat/http/configuration_error.rb +9 -0
- data/lib/flow_chat/http/gateway/simple.rb +104 -36
- data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
- data/lib/flow_chat/http/renderer.rb +3 -3
- data/lib/flow_chat/input.rb +86 -0
- data/lib/flow_chat/instagram/client.rb +32 -0
- data/lib/flow_chat/instagram/configuration.rb +147 -0
- data/lib/flow_chat/instagram/configuration_error.rb +7 -0
- data/lib/flow_chat/instagram/gateway/send_api.rb +63 -0
- data/lib/flow_chat/instagram/middleware/choice_mapper.rb +22 -0
- data/lib/flow_chat/instagram/renderer.rb +23 -0
- data/lib/flow_chat/instrumentation/metrics_collector.rb +6 -1
- data/lib/flow_chat/instrumentation/setup.rb +1 -1
- data/lib/flow_chat/instrumentation.rb +182 -0
- data/lib/flow_chat/intercom/client.rb +161 -0
- data/lib/flow_chat/intercom/configuration.rb +102 -0
- data/lib/flow_chat/intercom/configuration_error.rb +9 -0
- data/lib/flow_chat/intercom/gateway/intercom_api.rb +420 -0
- data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
- data/lib/flow_chat/intercom/renderer.rb +123 -0
- data/lib/flow_chat/media.rb +121 -0
- data/lib/flow_chat/messenger/client.rb +264 -0
- data/lib/flow_chat/messenger/configuration.rb +103 -0
- data/lib/flow_chat/messenger/configuration_error.rb +9 -0
- data/lib/flow_chat/messenger/gateway/send_api.rb +42 -0
- data/lib/flow_chat/messenger/middleware/choice_mapper.rb +185 -0
- data/lib/flow_chat/messenger/renderer.rb +150 -0
- data/lib/flow_chat/meta/challenge.rb +24 -0
- data/lib/flow_chat/meta/choice_ladder.rb +37 -0
- data/lib/flow_chat/meta/configuration_error.rb +7 -0
- data/lib/flow_chat/meta/gateway_identity.rb +38 -0
- data/lib/flow_chat/meta/messaging_gateway.rb +468 -0
- data/lib/flow_chat/meta/signature.rb +30 -0
- data/lib/flow_chat/meta/signature_validation.rb +66 -0
- data/lib/flow_chat/meta/webhook_verification.rb +43 -0
- data/lib/flow_chat/named_configuration.rb +65 -0
- data/lib/flow_chat/phone_number_util.rb +37 -35
- data/lib/flow_chat/processor.rb +188 -0
- data/lib/flow_chat/prompt.rb +13 -16
- data/lib/flow_chat/renderers/markdown_support.rb +167 -0
- data/lib/flow_chat/security.rb +76 -0
- data/lib/flow_chat/session/middleware.rb +36 -11
- data/lib/flow_chat/simulator/controller.rb +31 -15
- data/lib/flow_chat/simulator/views/simulator.html.erb +184 -20
- data/lib/flow_chat/telegram/client.rb +283 -0
- data/lib/flow_chat/telegram/configuration.rb +78 -0
- data/lib/flow_chat/telegram/configuration_error.rb +9 -0
- data/lib/flow_chat/telegram/gateway/bot_api.rb +318 -0
- data/lib/flow_chat/telegram/middleware/choice_mapper.rb +96 -0
- data/lib/flow_chat/telegram/renderer.rb +133 -0
- data/lib/flow_chat/telegram.rb +7 -0
- data/lib/flow_chat/text_truncator.rb +75 -0
- data/lib/flow_chat/ussd/gateway/nalo.rb +24 -4
- data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
- data/lib/flow_chat/ussd/middleware/pagination.rb +9 -5
- data/lib/flow_chat/ussd/renderer.rb +1 -1
- data/lib/flow_chat/version.rb +1 -1
- data/lib/flow_chat/whatsapp/client.rb +158 -20
- data/lib/flow_chat/whatsapp/configuration.rb +13 -52
- data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
- data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +335 -248
- data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +234 -0
- data/lib/flow_chat/whatsapp/renderer.rb +259 -64
- data/lib/flow_chat.rb +1 -1
- data/lib/tasks/release.rake +165 -0
- data/site/.nojekyll +0 -0
- data/site/.og-card.html +89 -0
- data/site/favicon.svg +6 -0
- data/site/index.html +209 -0
- data/site/og.png +0 -0
- metadata +132 -25
- data/docs/flows.md +0 -320
- data/docs/http-gateway-protocol.md +0 -432
- data/docs/images/simulator.png +0 -0
- data/docs/media.md +0 -153
- data/docs/sessions.md +0 -433
- data/docs/ussd-setup.md +0 -322
- data/docs/whatsapp-setup.md +0 -162
- data/examples/whatsapp_message_job.rb +0 -113
- data/lib/flow_chat/base_app.rb +0 -86
- data/lib/flow_chat/base_processor.rb +0 -146
- data/lib/flow_chat/http/app.rb +0 -6
- data/lib/flow_chat/http/middleware/executor.rb +0 -24
- data/lib/flow_chat/http/processor.rb +0 -33
- data/lib/flow_chat/session/rails_session_store.rb +0 -68
- data/lib/flow_chat/ussd/app.rb +0 -6
- data/lib/flow_chat/ussd/gateway/nsano.rb +0 -96
- data/lib/flow_chat/ussd/middleware/executor.rb +0 -24
- data/lib/flow_chat/ussd/processor.rb +0 -39
- data/lib/flow_chat/whatsapp/app.rb +0 -29
- data/lib/flow_chat/whatsapp/middleware/executor.rb +0 -24
- data/lib/flow_chat/whatsapp/processor.rb +0 -32
- data/lib/flow_chat/whatsapp/send_job_support.rb +0 -79
data/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# Contributing to Plutonium
|
|
2
|
+
|
|
3
|
+
## Commit Message Convention
|
|
4
|
+
|
|
5
|
+
This project follows [Conventional Commits](https://www.conventionalcommits.org/) for automated changelog generation and versioning.
|
|
6
|
+
|
|
7
|
+
### Format
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
<type>[optional scope]: <description>
|
|
11
|
+
|
|
12
|
+
[optional body]
|
|
13
|
+
|
|
14
|
+
[optional footer(s)]
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### Types
|
|
18
|
+
|
|
19
|
+
- **feat**: A new feature (triggers MINOR version bump)
|
|
20
|
+
- **fix**: A bug fix (triggers PATCH version bump)
|
|
21
|
+
- **docs**: Documentation only changes
|
|
22
|
+
- **style**: Changes that don't affect code meaning (formatting, etc)
|
|
23
|
+
- **refactor**: Code change that neither fixes a bug nor adds a feature
|
|
24
|
+
- **perf**: Performance improvement
|
|
25
|
+
- **test**: Adding or updating tests
|
|
26
|
+
- **chore**: Maintenance tasks (dependencies, tooling, etc)
|
|
27
|
+
|
|
28
|
+
### Breaking Changes
|
|
29
|
+
|
|
30
|
+
Add `BREAKING CHANGE:` in the footer or `!` after type to trigger a MAJOR version bump:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
feat!: remove deprecated API
|
|
34
|
+
|
|
35
|
+
BREAKING CHANGE: The old API has been removed. Use new API instead.
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Examples
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# Feature (bumps 0.26.11 -> 0.27.0)
|
|
42
|
+
git commit -m "feat: add field-level options support for input definitions"
|
|
43
|
+
|
|
44
|
+
# Bug fix (bumps 0.26.11 -> 0.26.12)
|
|
45
|
+
git commit -m "fix: resolve inheritance issue with controller_for"
|
|
46
|
+
|
|
47
|
+
# Breaking change (bumps 0.26.11 -> 1.0.0)
|
|
48
|
+
git commit -m "feat!: redesign definition DSL
|
|
49
|
+
|
|
50
|
+
BREAKING CHANGE: The definition DSL has been completely redesigned.
|
|
51
|
+
See migration guide for details."
|
|
52
|
+
|
|
53
|
+
# With scope
|
|
54
|
+
git commit -m "feat(ui): add new table component"
|
|
55
|
+
git commit -m "fix(forms): correct hint display on validation errors"
|
|
56
|
+
|
|
57
|
+
# Documentation
|
|
58
|
+
git commit -m "docs: update definition structure guide"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Release Process
|
|
62
|
+
|
|
63
|
+
### Option 1: Automated (Recommended)
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# See what the next version should be
|
|
67
|
+
rake release:next_version
|
|
68
|
+
|
|
69
|
+
# Prepare a new release (updates version, generates changelog)
|
|
70
|
+
rake release:prepare[0.27.0]
|
|
71
|
+
|
|
72
|
+
# Review changes
|
|
73
|
+
git diff
|
|
74
|
+
|
|
75
|
+
# Full automated release (prepare, commit, tag, push, publish)
|
|
76
|
+
rake release:full[0.27.0]
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Option 2: Manual
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
# 1. Update version in lib/plutonium/version.rb
|
|
83
|
+
# 2. Generate changelog
|
|
84
|
+
git-cliff --tag v0.27.0 -o CHANGELOG.md
|
|
85
|
+
|
|
86
|
+
# 3. Commit and tag
|
|
87
|
+
git add -A
|
|
88
|
+
git commit -m "chore(release): prepare for v0.27.0"
|
|
89
|
+
git tag v0.27.0
|
|
90
|
+
git push origin main --tags
|
|
91
|
+
|
|
92
|
+
# 4. GitHub Actions will automatically publish to RubyGems
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Version Bumping Rules
|
|
96
|
+
|
|
97
|
+
Following semantic versioning:
|
|
98
|
+
|
|
99
|
+
- **MAJOR** (X.0.0): Breaking changes
|
|
100
|
+
- **MINOR** (0.X.0): New features (backwards compatible)
|
|
101
|
+
- **PATCH** (0.0.X): Bug fixes (backwards compatible)
|
|
102
|
+
|
|
103
|
+
The automation determines the version bump based on commits since the last tag:
|
|
104
|
+
- Any commit with `BREAKING CHANGE:` or `!` after type → MAJOR
|
|
105
|
+
- Any `feat:` commits → MINOR
|
|
106
|
+
- Any `fix:` commits → PATCH
|
|
107
|
+
|
|
108
|
+
## Development Setup
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
# Install dependencies
|
|
112
|
+
bundle install
|
|
113
|
+
|
|
114
|
+
# Run tests
|
|
115
|
+
bundle exec rspec
|
|
116
|
+
|
|
117
|
+
# Install git-cliff for changelog generation (optional)
|
|
118
|
+
brew install git-cliff # macOS
|
|
119
|
+
# or
|
|
120
|
+
cargo install git-cliff # via Rust
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Pull Request Process
|
|
124
|
+
|
|
125
|
+
1. Fork the repository
|
|
126
|
+
2. Create a feature branch: `git checkout -b feat/my-feature`
|
|
127
|
+
3. Make your changes with conventional commits
|
|
128
|
+
4. Run tests: `bundle exec rspec`
|
|
129
|
+
5. Push and create a pull request
|
|
130
|
+
6. The PR title should also follow conventional commit format
|
|
131
|
+
|
|
132
|
+
## Questions?
|
|
133
|
+
|
|
134
|
+
Open an issue or discussion on GitHub!
|
data/Gemfile
CHANGED
data/README.md
CHANGED
|
@@ -3,207 +3,263 @@
|
|
|
3
3
|
[](https://github.com/radioactive-labs/flow_chat/actions/workflows/ci.yml)
|
|
4
4
|
[](https://badge.fury.io/rb/flow_chat)
|
|
5
5
|
[](https://opensource.org/licenses/MIT)
|
|
6
|
-
[](https://rubyonrails.org/)
|
|
6
|
+
[](https://www.ruby-lang.org/)
|
|
8
7
|
|
|
9
|
-
|
|
8
|
+
**Write a conversation as an ordinary Ruby method. FlowChat runs it across stateless webhooks, on every messaging channel.**
|
|
10
9
|
|
|
11
|
-
|
|
10
|
+
A USSD or chat webhook is stateless: each message arrives as an isolated POST with no memory of the ones before it. The usual answer is a hand-rolled state machine: persist the current step, switch on it when the next message comes in, run the transition, persist the next step, repeat. FlowChat replaces that machine with a session and a replay engine, so you write the flow as a synchronous script that reads top to bottom.
|
|
12
11
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
12
|
+
```ruby
|
|
13
|
+
class RegistrationFlow < FlowChat::Flow
|
|
14
|
+
def main_page
|
|
15
|
+
name = app.screen(:name) { |prompt| prompt.ask "What's your name?" }
|
|
16
|
+
|
|
17
|
+
email = app.screen(:email) do |prompt|
|
|
18
|
+
prompt.ask "Your email?", validate: ->(input) { "Invalid email" unless input.include?("@") }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
app.say "Welcome #{name}!"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Each `screen` returns its stored answer when one exists and re-prompts when it does not. The method runs top to bottom on every turn and blocks on the first screen that has no answer yet. The same flow runs unchanged on USSD, WhatsApp, Messenger, Instagram, Telegram, and HTTP, with per-platform rendering.
|
|
27
|
+
|
|
28
|
+
## How the replay engine works
|
|
29
|
+
|
|
30
|
+
There is no saved program counter. On each webhook FlowChat rebuilds the app from the request and re-runs your flow method from the first line.
|
|
31
|
+
|
|
32
|
+
`app.screen(key)` is the fast-forward mechanism. When the session already holds a value for `key`, `screen` returns `session.get(key)` immediately and never yields. When it does not, `screen` yields a `FlowChat::Prompt`. If that prompt calls `ask` (or `select`, `yes?`) and no input is available yet, it raises `FlowChat::Interrupt::Prompt`. The interrupt unwinds the flow back to the Executor, which turns it into a rendered prompt and returns the response to the gateway.
|
|
33
|
+
|
|
34
|
+
The next webhook replays the same method from the top. Every screen that already has a stored answer is skipped in place. The screen that raised last time now sees the incoming input, runs its `validate` and `transform`, stores the accepted value with `session.set(key, value)`, and returns it, so execution falls through into the next screen. The flow advances one screen per turn without ever holding state between requests beyond the session hash.
|
|
35
|
+
|
|
36
|
+
Two rules follow from this design:
|
|
37
|
+
|
|
38
|
+
- One inbound message is consumed by one screen per turn. Once a screen takes the turn's input, later screens in the same run see no input and will prompt.
|
|
39
|
+
- A given screen key may be presented only once per run. Re-entering a key raises `ArgumentError`; use distinct keys or `app.go_back` to revisit.
|
|
21
40
|
|
|
22
41
|
## Installation
|
|
23
42
|
|
|
24
|
-
Add to your Gemfile:
|
|
43
|
+
Add the gem to your Gemfile:
|
|
25
44
|
|
|
26
45
|
```ruby
|
|
27
|
-
gem
|
|
46
|
+
gem "flow_chat"
|
|
28
47
|
```
|
|
29
48
|
|
|
30
|
-
Then run
|
|
49
|
+
Then run `bundle install`. FlowChat requires Ruby 3.0 or newer.
|
|
31
50
|
|
|
32
|
-
|
|
33
|
-
|
|
51
|
+
The cache-backed session store needs a cache. Set it once during boot, for example in an initializer:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
FlowChat::Config.cache = Rails.cache
|
|
34
55
|
```
|
|
35
56
|
|
|
36
|
-
|
|
57
|
+
Without a cache configured, `FlowChat::Session::CacheSessionStore` raises when it tries to read or write a session. There are no migrations and no generators to run.
|
|
37
58
|
|
|
38
|
-
|
|
59
|
+
## Wiring a platform
|
|
39
60
|
|
|
40
|
-
|
|
61
|
+
Build a processor, choose a gateway and session store, and run your flow:
|
|
41
62
|
|
|
42
63
|
```ruby
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
prompt.ask "What's your name?",
|
|
47
|
-
transform: ->(input) { input.strip.titleize }
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
language = app.screen(:language) do |prompt|
|
|
51
|
-
prompt.select "Choose language:", ["English", "French", "Spanish"]
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
app.say "Hello #{name}! Language set to #{language}."
|
|
55
|
-
end
|
|
64
|
+
processor = FlowChat::Processor.new(self) do |config|
|
|
65
|
+
config.use_gateway FlowChat::Ussd::Gateway::Nalo
|
|
66
|
+
config.use_session_store FlowChat::Session::CacheSessionStore
|
|
56
67
|
end
|
|
68
|
+
|
|
69
|
+
processor.run RegistrationFlow, :main_page
|
|
57
70
|
```
|
|
58
71
|
|
|
59
|
-
|
|
72
|
+
Point your controller's webhook action at this code (`self` is the controller). Each platform has its own gateway class:
|
|
60
73
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
74
|
+
| Platform | Gateway class | Platform symbol | Rendering |
|
|
75
|
+
|---|---|---|---|
|
|
76
|
+
| USSD | `FlowChat::Ussd::Gateway::Nalo` | `:ussd` | Numbered text menus, pagination, media as URL text |
|
|
77
|
+
| WhatsApp | `FlowChat::Whatsapp::Gateway::CloudApi` | `:whatsapp` | Reply buttons (<=3 choices), lists (>3), rich media |
|
|
78
|
+
| Messenger | `FlowChat::Messenger::Gateway::SendApi` | `:messenger` | Quick replies, carousel, numbered text |
|
|
79
|
+
| Instagram | `FlowChat::Instagram::Gateway::SendApi` | `:instagram` | Quick replies, carousel, always numbered |
|
|
80
|
+
| Telegram | `FlowChat::Telegram::Gateway::BotApi` | `:telegram` | Inline keyboards, rich media, HTML |
|
|
81
|
+
| HTTP | `FlowChat::Http::Gateway::Simple` | `:http` | JSON responses (testing, custom clients) |
|
|
82
|
+
| Intercom | `FlowChat::Intercom::Gateway::IntercomApi` | `:intercom` | Live-chat replies |
|
|
64
83
|
|
|
65
|
-
|
|
66
|
-
processor = FlowChat::Ussd::Processor.new(self) do |config|
|
|
67
|
-
config.use_gateway FlowChat::Ussd::Gateway::Nalo
|
|
68
|
-
config.use_session_store FlowChat::Session::CacheSessionStore
|
|
69
|
-
end
|
|
84
|
+
The same `RegistrationFlow` runs on any row by changing only `config.use_gateway`. To build a gateway for a platform not listed here, see [docs/gateway-development.md](docs/gateway-development.md).
|
|
70
85
|
|
|
71
|
-
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
```
|
|
86
|
+
## Building flows
|
|
75
87
|
|
|
76
|
-
|
|
88
|
+
A flow is a class that inherits `FlowChat::Flow` and reads and writes the conversation through `app`. Each unit of interaction is a screen:
|
|
77
89
|
|
|
78
90
|
```ruby
|
|
79
|
-
|
|
91
|
+
value = app.screen(:key) { |prompt| prompt.ask "..." }
|
|
80
92
|
```
|
|
81
93
|
|
|
82
|
-
|
|
94
|
+
The block receives a `FlowChat::Prompt`. Its methods:
|
|
83
95
|
|
|
84
|
-
|
|
96
|
+
| Method | Signature | Behavior |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| `ask` | `ask(msg, choices: nil, transform: nil, validate: nil, media: nil)` | Prompt for free input; validate, then transform, then return the value |
|
|
99
|
+
| `select` | `select(msg, choices, media: nil, error_message: "Invalid selection:")` | Prompt for one of `choices`; returns the chosen key |
|
|
100
|
+
| `yes?` | `yes?(msg)` | A `select` over Yes/No; returns `true` or `false` |
|
|
101
|
+
| `say` | `say(msg, media: nil)` | Send a terminal message and end the flow |
|
|
85
102
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
```
|
|
103
|
+
Details:
|
|
104
|
+
|
|
105
|
+
- `validate` is a callable that returns an error string when the input is rejected and `nil` when it passes. The received argument behaves like the input text (`input.include?("@")`, `input.to_i`, `input.strip`), and also exposes `media`, `location`, and `contact` for attachment-carrying turns.
|
|
106
|
+
- `transform` is a callable that maps the accepted input to the value `screen` stores and returns.
|
|
107
|
+
- `choices` may be an Array (`["Yes", "No"]`) or a Hash (`{ "1" => "Account", "2" => "Support" }`). With a Hash, `select` returns the key.
|
|
108
|
+
- Passing `media:` together with more than 3 `choices` raises `ArgumentError`. Use media or a longer choice list, not both.
|
|
93
109
|
|
|
94
|
-
|
|
110
|
+
`app.say` outside a block ends the flow from anywhere:
|
|
95
111
|
|
|
96
112
|
```ruby
|
|
97
|
-
|
|
98
|
-
|
|
113
|
+
def main_page
|
|
114
|
+
confirmed = app.screen(:confirm) { |prompt| prompt.yes? "Place the order?" }
|
|
115
|
+
app.say("Cancelled.") unless confirmed
|
|
116
|
+
# ...
|
|
117
|
+
end
|
|
118
|
+
```
|
|
99
119
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
120
|
+
To send the user back to the previous screen, call `app.go_back`. It clears the current screen's stored answer and restarts the flow from the top, so the earlier screen prompts again. Remember that a screen key may appear only once per run: reuse of a key in a single pass raises `ArgumentError`.
|
|
121
|
+
|
|
122
|
+
## Media and rich input
|
|
123
|
+
|
|
124
|
+
An inbound turn is a `FlowChat::Input`. Read what the user sent through these accessors on `app`:
|
|
125
|
+
|
|
126
|
+
| Accessor | Returns |
|
|
127
|
+
|---|---|
|
|
128
|
+
| `app.text` | The message text (`""` when the turn carries only an attachment) |
|
|
129
|
+
| `app.media` | An Array of `FlowChat::Media` (empty array when none) |
|
|
130
|
+
| `app.location` | The shared location payload, or `nil` |
|
|
131
|
+
| `app.contact` | The shared contact card, or `nil` |
|
|
132
|
+
| `app.attachment_type` | `:media`, `:location`, `:contact`, or `nil` |
|
|
133
|
+
|
|
134
|
+
Reading an inbound photo:
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
def main_page
|
|
138
|
+
app.screen(:photo) do |prompt|
|
|
139
|
+
prompt.ask "Send a photo of the receipt."
|
|
140
|
+
end
|
|
105
141
|
|
|
106
|
-
|
|
142
|
+
photo = app.media.first
|
|
143
|
+
if photo
|
|
144
|
+
photo.type # => :image (a Symbol; normalized across platforms)
|
|
145
|
+
photo.mime_type # => "image/jpeg"
|
|
146
|
+
photo.caption # => the caption text, or nil
|
|
147
|
+
bytes = photo.download # raw bytes, or nil on failure
|
|
148
|
+
link = photo.url # a fetchable URL, or nil
|
|
107
149
|
end
|
|
108
150
|
end
|
|
109
151
|
```
|
|
110
152
|
|
|
111
|
-
|
|
153
|
+
`app.media` is always an Array, so read a single item with `app.media.first` and check for `nil`; the type is a Symbol, read with `app.media.first&.type`. A caption-less photo still answers a screen: an attachment counts as submitted even when the text is blank.
|
|
154
|
+
|
|
155
|
+
To send media outbound, pass `media:` to `ask` or `say`:
|
|
112
156
|
|
|
113
157
|
```ruby
|
|
114
|
-
|
|
158
|
+
app.say "Here you go", media: { type: :image, url: "https://example.com/receipt.png" }
|
|
115
159
|
```
|
|
116
160
|
|
|
117
|
-
|
|
161
|
+
Caveat: a `Media` object that has been deserialized from a session store or a background job has lost its live platform client, so `url` and `download` return `nil`. If you need the bytes, call `download` eagerly in the same request that received the media, before the value round-trips through a session or job.
|
|
118
162
|
|
|
119
|
-
|
|
163
|
+
## Sessions
|
|
120
164
|
|
|
121
|
-
|
|
165
|
+
The default session boundaries are `[:flow, :gateway, :platform]`: a session is scoped to one flow, on one gateway, on one platform. Convenience methods adjust this:
|
|
122
166
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
transform: ->(input) { input.downcase.strip }
|
|
130
|
-
end
|
|
167
|
+
| Method | Effect |
|
|
168
|
+
|---|---|
|
|
169
|
+
| `use_durable_sessions` | Key the session on a stable per-user identifier so it survives USSD session-id rotation across a conversation |
|
|
170
|
+
| `use_cross_platform_sessions` | Narrow the boundary to flow only, so one session is shared across platforms (for example USSD and WhatsApp) |
|
|
171
|
+
| `use_url_isolation` | Add a `:url` boundary for per-tenant or per-host isolation |
|
|
172
|
+
| `use_session_config(boundaries:, identifier:, hash_identifiers:, &block)` | Set boundaries, the identifier, and identifier hashing directly for full control |
|
|
131
173
|
|
|
132
|
-
|
|
133
|
-
|
|
174
|
+
```ruby
|
|
175
|
+
processor = FlowChat::Processor.new(self) do |config|
|
|
176
|
+
config.use_gateway FlowChat::Whatsapp::Gateway::CloudApi
|
|
177
|
+
config.use_session_store FlowChat::Session::CacheSessionStore
|
|
178
|
+
config.use_durable_sessions
|
|
134
179
|
end
|
|
180
|
+
```
|
|
135
181
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
182
|
+
See [docs/configuration.md](docs/configuration.md) for the full set of session options.
|
|
183
|
+
|
|
184
|
+
## Platform differences
|
|
185
|
+
|
|
186
|
+
The flow code is the same everywhere, but each platform imposes limits that the rendering respects and that you should keep in mind:
|
|
187
|
+
|
|
188
|
+
| Platform | Limits and behavior |
|
|
189
|
+
|---|---|
|
|
190
|
+
| USSD | Pages are split at 140 characters by default; media is rendered as a text line containing a URL, not an inline attachment; async processing is not available. |
|
|
191
|
+
| WhatsApp | Reply-button titles are truncated near 20 characters and list titles near 24; a list section holds at most 10 rows. WhatsApp's 24-hour customer-service window and its template requirement are not abstracted away: you manage message templates yourself. |
|
|
192
|
+
| Messenger | Up to 13 choices render as quick replies, up to 30 as a carousel (10 elements, 3 buttons each), and above that as numbered text. Neither platform abstracts the 24-hour messaging window; a send outside it is attempted and Meta's rejection surfaces as an API error. |
|
|
193
|
+
| Instagram | Same quick-reply and carousel caps as Messenger (13, then 30), but both surfaces render on the mobile app only, not desktop or web, so Instagram always lists the options as numbered text as well. Text is capped at 1000 bytes UTF-8, not characters. |
|
|
194
|
+
| Telegram | Choice taps arrive as callback queries; choices render as inline keyboards; message text supports HTML formatting. |
|
|
195
|
+
| HTTP | Requests and responses are JSON; each request must supply `session_id` and `user_id`. |
|
|
196
|
+
|
|
197
|
+
Platform guides: [docs/platforms/ussd.md](docs/platforms/ussd.md), [docs/platforms/whatsapp.md](docs/platforms/whatsapp.md), [docs/platforms/messenger.md](docs/platforms/messenger.md), [docs/platforms/instagram.md](docs/platforms/instagram.md), [docs/platforms/telegram.md](docs/platforms/telegram.md).
|
|
198
|
+
|
|
199
|
+
## Background processing
|
|
200
|
+
|
|
201
|
+
For platforms with an outbound API, you can acknowledge the webhook immediately and run the flow in a background job. Register a factory once, then call it from the webhook:
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
# config/initializers/flow_chat.rb
|
|
205
|
+
FlowChat::Factory.register(:whatsapp) do |controller|
|
|
206
|
+
processor = FlowChat::Processor.new(controller) do |config|
|
|
207
|
+
config.use_gateway FlowChat::Whatsapp::Gateway::CloudApi
|
|
208
|
+
config.use_session_store FlowChat::Session::CacheSessionStore
|
|
209
|
+
config.use_async(factory: :whatsapp)
|
|
142
210
|
end
|
|
211
|
+
processor.run RegistrationFlow, :main_page
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# app/controllers/whatsapp_controller.rb
|
|
215
|
+
def webhook
|
|
216
|
+
FlowChat::Factory.execute(:whatsapp, controller: self)
|
|
143
217
|
end
|
|
144
218
|
```
|
|
145
219
|
|
|
146
|
-
|
|
220
|
+
The webhook enqueues `GenericAsyncJob` and returns; the job re-runs the same factory in the background, where the gateway detects the background context and processes the flow inline before sending the reply through the platform API. USSD does not support async, since it depends on a synchronous request-response cycle. See [docs/factory-pattern.md](docs/factory-pattern.md) and [docs/async-background-processing.md](docs/async-background-processing.md).
|
|
147
221
|
|
|
148
|
-
|
|
149
|
-
- **`prompt.select()`** - Force selection from predefined options
|
|
150
|
-
- **`prompt.yes?()`** - Simple yes/no questions
|
|
222
|
+
## Instrumentation
|
|
151
223
|
|
|
152
|
-
|
|
224
|
+
FlowChat emits `ActiveSupport::Notifications` events for flow execution, session lifecycle, and gateway activity. `FlowChat.metrics` collects counters over those events. See [docs/instrumentation.md](docs/instrumentation.md).
|
|
153
225
|
|
|
154
|
-
|
|
226
|
+
## Testing
|
|
155
227
|
|
|
156
|
-
|
|
157
|
-
config = FlowChat::Whatsapp::Configuration.from_credentials
|
|
158
|
-
client = FlowChat::Whatsapp::Client.new(config)
|
|
159
|
-
|
|
160
|
-
# Send messages
|
|
161
|
-
client.send_text("+1234567890", "Hello!")
|
|
162
|
-
client.send_buttons("+1234567890", "Choose:", [
|
|
163
|
-
{ id: 'option1', title: 'Option 1' },
|
|
164
|
-
{ id: 'option2', title: 'Option 2' }
|
|
165
|
-
])
|
|
166
|
-
```
|
|
228
|
+
FlowChat ships a web simulator for driving flows locally without a real gateway, useful during development. It requires `FlowChat::Config.simulator_secret` to be set. See [docs/testing.md](docs/testing.md).
|
|
167
229
|
|
|
168
|
-
##
|
|
230
|
+
## Documentation
|
|
169
231
|
|
|
170
|
-
|
|
232
|
+
Guides:
|
|
171
233
|
|
|
172
|
-
|
|
234
|
+
- [Getting started](docs/getting-started.md)
|
|
235
|
+
- [Configuration](docs/configuration.md)
|
|
236
|
+
- [Testing](docs/testing.md)
|
|
173
237
|
|
|
174
|
-
|
|
175
|
-
- 📱 **Visual Interface** - Phone-like display with real conversation flow
|
|
176
|
-
- 🔄 **Environment Switching** - Toggle between USSD and WhatsApp modes
|
|
177
|
-
- 📊 **Request Logging** - Real-time HTTP request/response monitoring
|
|
178
|
-
- 🎯 **Interactive Testing** - Test flows with character counting and validation
|
|
179
|
-
- 🛠️ **Developer Tools** - Session management and connection status
|
|
238
|
+
Platforms:
|
|
180
239
|
|
|
181
|
-
|
|
240
|
+
- [USSD](docs/platforms/ussd.md)
|
|
241
|
+
- [WhatsApp](docs/platforms/whatsapp.md)
|
|
242
|
+
- [Messenger](docs/platforms/messenger.md)
|
|
243
|
+
- [Instagram](docs/platforms/instagram.md)
|
|
244
|
+
- [Telegram](docs/platforms/telegram.md)
|
|
182
245
|
|
|
246
|
+
Internals and advanced:
|
|
183
247
|
|
|
184
|
-
|
|
248
|
+
- [Architecture](docs/architecture.md)
|
|
249
|
+
- [Building a gateway](docs/gateway-development.md)
|
|
250
|
+
- [Gateway context variables](docs/gateway-context-variables.md)
|
|
251
|
+
- [Async and background processing](docs/async-background-processing.md)
|
|
252
|
+
- [Factory pattern](docs/factory-pattern.md)
|
|
253
|
+
- [Instrumentation](docs/instrumentation.md)
|
|
185
254
|
|
|
186
|
-
|
|
187
|
-
- **[USSD Setup](docs/ussd-setup.md)** - USSD gateway configuration and examples
|
|
188
|
-
- **[Flow Development](docs/flows.md)** - Advanced flow patterns and techniques
|
|
189
|
-
- **[Session Management](docs/sessions.md)** - Session architecture and configuration
|
|
190
|
-
- **[Media Support](docs/media.md)** - Rich media handling for WhatsApp
|
|
191
|
-
- **[Testing Guide](docs/testing.md)** - Complete testing strategies
|
|
192
|
-
- **[Configuration Reference](docs/configuration.md)** - All configuration options
|
|
193
|
-
- **[Instrumentation](docs/instrumentation.md)** - Monitoring and metrics
|
|
194
|
-
- **[Security](docs/security.md)** - Security best practices
|
|
195
|
-
- **[Examples](examples/)** - Complete working examples
|
|
255
|
+
## Development
|
|
196
256
|
|
|
197
|
-
|
|
257
|
+
Run the test suite with `rake test`. To run a single file, use `ruby -Itest test/unit/some_test.rb`.
|
|
198
258
|
|
|
199
|
-
|
|
259
|
+
## Contributing
|
|
200
260
|
|
|
201
|
-
|
|
202
|
-
- **[WhatsApp Controller](examples/whatsapp_controller.rb)** - Basic WhatsApp setup
|
|
203
|
-
- **[Multi-tenant WhatsApp](examples/multi_tenant_whatsapp_controller.rb)** - Advanced WhatsApp
|
|
204
|
-
- **[Background Jobs](examples/whatsapp_message_job.rb)** - Async message processing
|
|
205
|
-
- **[Simulator Controller](examples/simulator_controller.rb)** - Testing setup
|
|
261
|
+
Bug reports and pull requests are welcome on [GitHub](https://github.com/radioactive-labs/flow_chat). Please add tests for behavior changes and keep them passing.
|
|
206
262
|
|
|
207
263
|
## License
|
|
208
264
|
|
|
209
|
-
|
|
265
|
+
FlowChat is released under the [MIT License](LICENSE.txt).
|
data/Rakefile
CHANGED
|
@@ -1,10 +1,25 @@
|
|
|
1
1
|
require "bundler/gem_tasks"
|
|
2
2
|
require "rake/testtask"
|
|
3
|
+
require "standard/rake"
|
|
4
|
+
|
|
5
|
+
# Load custom rake tasks
|
|
6
|
+
Dir.glob("lib/tasks/**/*.rake").each { |r| load r }
|
|
3
7
|
|
|
4
8
|
Rake::TestTask.new(:test) do |t|
|
|
5
9
|
t.libs << "test"
|
|
6
10
|
t.libs << "lib"
|
|
7
|
-
|
|
11
|
+
# test/performance holds benchmarks, which assert against wall-clock timings and
|
|
12
|
+
# so fail on a loaded machine for reasons that have nothing to do with the code
|
|
13
|
+
# under test. A suite that cries wolf is a suite people stop reading, so they
|
|
14
|
+
# run under `rake benchmark` instead of here.
|
|
15
|
+
t.test_files = FileList["test/**/*_test.rb"].exclude("test/performance/**/*_test.rb")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
desc "Run the performance benchmarks (timing-sensitive, not part of rake test)"
|
|
19
|
+
Rake::TestTask.new(:benchmark) do |t|
|
|
20
|
+
t.libs << "test"
|
|
21
|
+
t.libs << "lib"
|
|
22
|
+
t.test_files = FileList["test/performance/**/*_test.rb"]
|
|
8
23
|
end
|
|
9
24
|
|
|
10
|
-
task default:
|
|
25
|
+
task default: %i[test standard]
|