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
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
@@ -8,3 +8,4 @@ gem "minitest", "~> 5.0"
8
8
  gem "minitest-reporters", "~> 1.4"
9
9
  gem "ostruct"
10
10
  gem "webmock", "~> 3.18"
11
+ gem "standard", "~> 1.3"
data/README.md CHANGED
@@ -3,207 +3,263 @@
3
3
  [![CI](https://github.com/radioactive-labs/flow_chat/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/radioactive-labs/flow_chat/actions/workflows/ci.yml)
4
4
  [![Gem Version](https://badge.fury.io/rb/flow_chat.svg)](https://badge.fury.io/rb/flow_chat)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
- [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%202.3.0-red.svg)](https://www.ruby-lang.org/)
7
- [![Rails](https://img.shields.io/badge/rails-%3E%3D%206.0-red.svg)](https://rubyonrails.org/)
6
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.0-red.svg)](https://www.ruby-lang.org/)
8
7
 
9
- FlowChat is a Rails framework for building sophisticated conversational workflows for USSD and WhatsApp messaging. Define multi-step conversations as Ruby classes with automatic session management, input validation, and cross-platform compatibility.
8
+ **Write a conversation as an ordinary Ruby method. FlowChat runs it across stateless webhooks, on every messaging channel.**
10
9
 
11
- ## Key Features
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
- - 🎯 **Declarative Flow Definition** - Define conversations as Ruby classes
14
- - 🔄 **Automatic Session Management** - Persistent state across requests
15
- - ✅ **Input Validation & Transformation** - Built-in validation and data conversion
16
- - 📱 **USSD & WhatsApp Support** - Single codebase, multiple platforms
17
- - 💬 **Rich WhatsApp Features** - Interactive buttons, lists, media support
18
- - 🔧 **Standalone WhatsApp Client** - Send messages outside of flows
19
- - 📊 **Built-in Instrumentation** - Monitoring and metrics out of the box
20
- - 🧪 **Testing Tools** - Built-in simulator for development and testing
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 'flow_chat'
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
- ```bash
33
- bundle install
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
- ## Quick Start
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
- ### USSD Example
59
+ ## Wiring a platform
39
60
 
40
- Create a flow in `app/flow_chat/welcome_flow.rb`:
61
+ Build a processor, choose a gateway and session store, and run your flow:
41
62
 
42
63
  ```ruby
43
- class WelcomeFlow < FlowChat::Flow
44
- def main_page
45
- name = app.screen(:name) do |prompt|
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
- Create a controller:
72
+ Point your controller's webhook action at this code (`self` is the controller). Each platform has its own gateway class:
60
73
 
61
- ```ruby
62
- class UssdController < ApplicationController
63
- skip_forgery_protection
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
- def process_request
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
- processor.run WelcomeFlow, :main_page
72
- end
73
- end
74
- ```
86
+ ## Building flows
75
87
 
76
- Add route in `config/routes.rb`:
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
- post 'ussd' => 'ussd#process_request'
91
+ value = app.screen(:key) { |prompt| prompt.ask "..." }
80
92
  ```
81
93
 
82
- ### WhatsApp Example
94
+ The block receives a `FlowChat::Prompt`. Its methods:
83
95
 
84
- Configure credentials in `config/credentials.yml.enc`:
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
- ```yaml
87
- whatsapp:
88
- access_token: "your_access_token"
89
- phone_number_id: "your_phone_number_id"
90
- verify_token: "your_verify_token"
91
- app_secret: "your_app_secret"
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
- Create a controller:
110
+ `app.say` outside a block ends the flow from anywhere:
95
111
 
96
112
  ```ruby
97
- class WhatsappController < ApplicationController
98
- skip_forgery_protection
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
- def webhook
101
- processor = FlowChat::Whatsapp::Processor.new(self) do |config|
102
- config.use_gateway FlowChat::Whatsapp::Gateway::CloudApi
103
- config.use_session_store FlowChat::Session::CacheSessionStore
104
- end
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
- processor.run WelcomeFlow, :main_page
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
- Add route:
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
- match '/whatsapp/webhook', to: 'whatsapp#webhook', via: [:get, :post]
158
+ app.say "Here you go", media: { type: :image, url: "https://example.com/receipt.png" }
115
159
  ```
116
160
 
117
- ## Core Concepts
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
- ### Flows and Screens
163
+ ## Sessions
120
164
 
121
- **Flows** define conversation logic. **Screens** collect user input with automatic validation:
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
- ```ruby
124
- class RegistrationFlow < FlowChat::Flow
125
- def main_page
126
- email = app.screen(:email) do |prompt|
127
- prompt.ask "Enter email:",
128
- validate: ->(input) { "Invalid email" unless input.include?("@") },
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
- confirmed = app.screen(:confirm) do |prompt|
133
- prompt.yes? "Create account for #{email}?"
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
- if confirmed
137
- create_account(email)
138
- app.say "Account created successfully!"
139
- else
140
- app.say "Registration cancelled."
141
- end
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
- ### Input Methods
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
- - **`prompt.ask()`** - Free-form input with optional validation
149
- - **`prompt.select()`** - Force selection from predefined options
150
- - **`prompt.yes?()`** - Simple yes/no questions
222
+ ## Instrumentation
151
223
 
152
- ## WhatsApp Client
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
- Send messages outside of flows:
226
+ ## Testing
155
227
 
156
- ```ruby
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
- ## Testing Simulator
230
+ ## Documentation
169
231
 
170
- FlowChat includes a powerful built-in simulator with a modern web interface for testing both USSD and WhatsApp flows:
232
+ Guides:
171
233
 
172
- ![FlowChat Simulator](docs/images/simulator.png)
234
+ - [Getting started](docs/getting-started.md)
235
+ - [Configuration](docs/configuration.md)
236
+ - [Testing](docs/testing.md)
173
237
 
174
- Features:
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
- See the [Testing Guide](docs/testing.md) for complete setup instructions and testing strategies.
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
- ## Documentation
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
- - **[WhatsApp Setup](docs/whatsapp-setup.md)** - Comprehensive WhatsApp configuration
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
- ## Examples
257
+ Run the test suite with `rake test`. To run a single file, use `ruby -Itest test/unit/some_test.rb`.
198
258
 
199
- See the [examples directory](examples/) for complete implementations:
259
+ ## Contributing
200
260
 
201
- - **[USSD Controller](examples/ussd_controller.rb)** - Full USSD implementation
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
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
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
- t.test_files = FileList["test/**/*_test.rb"]
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: :test
25
+ task default: %i[test standard]