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
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Release flow
|
|
4
|
+
# ------------
|
|
5
|
+
# Publishing happens from a laptop. CI does NOT push to any registry — it only
|
|
6
|
+
# cuts the GitHub Release (with notes + the built gem) when the tag lands.
|
|
7
|
+
#
|
|
8
|
+
# 1. rake release:prepare # auto-computes next version via git-cliff
|
|
9
|
+
# rake release:prepare[1.2.3] # ...or pass one explicitly
|
|
10
|
+
# # → bumps + regenerates changelog, STAGES
|
|
11
|
+
# # the changes, and shows the diff. Nothing
|
|
12
|
+
# # is committed.
|
|
13
|
+
# 2. git diff --cached # review the staged changes
|
|
14
|
+
# 3. rake release:publish # commits, publishes the gem, then tags + pushes
|
|
15
|
+
# # → CI cuts the Release from the tag
|
|
16
|
+
#
|
|
17
|
+
# To abort after prepare: git reset --hard (discards the staged changes).
|
|
18
|
+
# release:publish is idempotent and resumable: it skips a gem already live and
|
|
19
|
+
# only tags if the tag is missing, so a partial failure can just be re-run.
|
|
20
|
+
|
|
21
|
+
RELEASE_CLIFF_CONFIG = ".cliff.toml"
|
|
22
|
+
RELEASE_VERSION_FILE = "lib/flow_chat/version.rb"
|
|
23
|
+
RELEASE_GEM_NAME = "flow_chat"
|
|
24
|
+
RELEASE_SECURITY_FILE = "SECURITY.md"
|
|
25
|
+
|
|
26
|
+
namespace :release do
|
|
27
|
+
# --- helpers --------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
def current_version
|
|
30
|
+
File.read(RELEASE_VERSION_FILE)[/VERSION = "([\d.]+)"/, 1] ||
|
|
31
|
+
abort("Could not read VERSION from #{RELEASE_VERSION_FILE}")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def git_cliff?
|
|
35
|
+
system("which git-cliff > /dev/null 2>&1")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Next version per conventional commits. git-cliff owns the semver math
|
|
39
|
+
# (including the pre-1.0 rules configured under [bump] in .cliff.toml).
|
|
40
|
+
def computed_next_version
|
|
41
|
+
abort "git-cliff not found. Install with: brew install git-cliff" unless git_cliff?
|
|
42
|
+
bumped = `git-cliff --config #{RELEASE_CLIFF_CONFIG} --bumped-version 2>/dev/null`.strip
|
|
43
|
+
abort "git-cliff could not compute a version (no conventional commits since last tag?)" if bumped.empty?
|
|
44
|
+
bumped.delete_prefix("v")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def gem_published?(version)
|
|
48
|
+
out = `gem list --remote --exact --all #{RELEASE_GEM_NAME} 2>/dev/null`
|
|
49
|
+
out.include?("#{version},") || out.include?("#{version})") || out.include?(" #{version} ")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# --- version --------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
desc "Show the next version computed from conventional commits"
|
|
55
|
+
task :version do
|
|
56
|
+
puts "Current version: #{current_version}"
|
|
57
|
+
puts "Next version: #{computed_next_version}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# --- prepare --------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
desc "Stage a release (bump + changelog) for review. Version optional; git-cliff computes it."
|
|
63
|
+
task :prepare, [:version] do |_t, args|
|
|
64
|
+
version = args[:version] || computed_next_version
|
|
65
|
+
|
|
66
|
+
unless version.match?(/^\d+\.\d+\.\d+$/)
|
|
67
|
+
abort "Error: version must be in format X.Y.Z (got #{version.inspect})"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
unless `git status --porcelain`.strip.empty?
|
|
71
|
+
abort "Error: working tree is dirty. Commit or stash first."
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
puts "Preparing release v#{version}..."
|
|
75
|
+
|
|
76
|
+
# Bump version.rb
|
|
77
|
+
content = File.read(RELEASE_VERSION_FILE)
|
|
78
|
+
File.write(RELEASE_VERSION_FILE, content.gsub(/VERSION = "[\d.]+"/, %(VERSION = "#{version}")))
|
|
79
|
+
puts "✓ #{RELEASE_VERSION_FILE}"
|
|
80
|
+
|
|
81
|
+
# Bump the supported-version series in SECURITY.md (e.g. `0.8.x`), so the
|
|
82
|
+
# policy always names the current release series. Skipped if the file or the
|
|
83
|
+
# expected `X.Y.x` marker is missing — a release must never fail over this.
|
|
84
|
+
if File.exist?(RELEASE_SECURITY_FILE)
|
|
85
|
+
series = "#{version.split(".").first(2).join(".")}.x"
|
|
86
|
+
security = File.read(RELEASE_SECURITY_FILE)
|
|
87
|
+
updated = security.gsub(/`\d+\.\d+\.x`/, "`#{series}`")
|
|
88
|
+
if updated == security
|
|
89
|
+
puts "• #{RELEASE_SECURITY_FILE} — no `X.Y.x` marker found, skipping"
|
|
90
|
+
else
|
|
91
|
+
File.write(RELEASE_SECURITY_FILE, updated)
|
|
92
|
+
puts "✓ #{RELEASE_SECURITY_FILE}"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Changelog — same config CI uses for release notes, so they agree.
|
|
97
|
+
abort "git-cliff not found. Install with: brew install git-cliff" unless git_cliff?
|
|
98
|
+
system("git-cliff", "--config", RELEASE_CLIFF_CONFIG, "--tag", "v#{version}", "-o", "CHANGELOG.md") ||
|
|
99
|
+
abort("Changelog generation failed")
|
|
100
|
+
puts "✓ CHANGELOG.md"
|
|
101
|
+
|
|
102
|
+
# Stage everything and show it — review happens BEFORE anything is committed.
|
|
103
|
+
system("git", "add", "-A") || abort("git add failed")
|
|
104
|
+
|
|
105
|
+
puts "\n✓ Staged release v#{version} (nothing committed yet)."
|
|
106
|
+
puts "\nStaged changes:"
|
|
107
|
+
system("git", "--no-pager", "diff", "--cached", "--stat")
|
|
108
|
+
puts "\nNext:"
|
|
109
|
+
puts " git diff --cached # review the full diff"
|
|
110
|
+
puts " rake release:publish # commit, publish gem, tag + push"
|
|
111
|
+
puts " git reset --hard # abort and discard the staged changes"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# --- publish (primary; idempotent + resumable) ----------------------------
|
|
115
|
+
|
|
116
|
+
desc "Commit the prepared release, publish the gem, then tag + push (fires the Release workflow)"
|
|
117
|
+
task :publish do
|
|
118
|
+
version = current_version
|
|
119
|
+
tag = "v#{version}"
|
|
120
|
+
|
|
121
|
+
# Commit the changes prepare left staged for review. If the tree is already
|
|
122
|
+
# clean (e.g. re-running after a partial failure), there's nothing to commit.
|
|
123
|
+
if `git status --porcelain`.strip.empty?
|
|
124
|
+
puts "• working tree clean — nothing to commit"
|
|
125
|
+
else
|
|
126
|
+
system("git", "add", "-A") || abort("git add failed")
|
|
127
|
+
system("git", "commit", "-m", "chore(release): prepare for v#{version}") || abort("git commit failed")
|
|
128
|
+
puts "✓ Committed release v#{version}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Gem (skip if this version is already on RubyGems)
|
|
132
|
+
if gem_published?(version)
|
|
133
|
+
puts "• gem #{RELEASE_GEM_NAME} #{version} already on RubyGems — skipping"
|
|
134
|
+
else
|
|
135
|
+
puts "Building + pushing gem..."
|
|
136
|
+
system("gem build #{RELEASE_GEM_NAME}.gemspec") || abort("Gem build failed")
|
|
137
|
+
gem_file = "#{RELEASE_GEM_NAME}-#{version}.gem"
|
|
138
|
+
system("gem push #{gem_file}") || abort("Gem push failed")
|
|
139
|
+
File.delete(gem_file) if File.exist?(gem_file)
|
|
140
|
+
puts "✓ Published #{RELEASE_GEM_NAME} #{version} to RubyGems"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Tag + push last, so CI cuts the Release only once the gem is live.
|
|
144
|
+
branch = `git branch --show-current`.strip
|
|
145
|
+
if system("git rev-parse #{tag} >/dev/null 2>&1")
|
|
146
|
+
puts "• tag #{tag} already exists — skipping tag"
|
|
147
|
+
else
|
|
148
|
+
system("git", "tag", tag) || abort("git tag failed")
|
|
149
|
+
end
|
|
150
|
+
system("git", "push", "origin", branch) || abort("git push branch failed")
|
|
151
|
+
system("git", "push", "origin", tag) || abort("git push tag failed")
|
|
152
|
+
|
|
153
|
+
puts "\n✓ Released #{tag}. GitHub Actions will cut the Release from the tag."
|
|
154
|
+
puts " Watch: https://github.com/radioactive-labs/flow_chat/actions"
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Neutralize the dangerous bare `rake release` that bundler/gem_tasks defines
|
|
159
|
+
# (it would tag + gem push directly). Point people at the real flow instead.
|
|
160
|
+
if Rake::Task.task_defined?("release")
|
|
161
|
+
Rake::Task["release"].clear
|
|
162
|
+
task :release do
|
|
163
|
+
warn "Use `rake release:prepare` then `rake release:publish`. See lib/tasks/release.rake."
|
|
164
|
+
end
|
|
165
|
+
end
|
data/site/.nojekyll
ADDED
|
File without changes
|
data/site/.og-card.html
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<!--
|
|
3
|
+
Source for the social preview image (og.png), rendered at 1200x630.
|
|
4
|
+
Regenerate after editing this file:
|
|
5
|
+
|
|
6
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
|
|
7
|
+
--headless --disable-gpu --hide-scrollbars --force-device-scale-factor=1 \
|
|
8
|
+
--window-size=1200,630 \
|
|
9
|
+
--screenshot="site/og.png" "file://$PWD/site/.og-card.html"
|
|
10
|
+
|
|
11
|
+
Any headless Chromium works. Keep the window size at 1200x630 (the 1.91:1
|
|
12
|
+
ratio OpenGraph/Twitter scrapers expect). This file is a dotfile so it stays
|
|
13
|
+
out of normal listings; it is only ever opened locally to produce og.png.
|
|
14
|
+
-->
|
|
15
|
+
<html lang="en">
|
|
16
|
+
<head>
|
|
17
|
+
<meta charset="utf-8">
|
|
18
|
+
<style>
|
|
19
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
20
|
+
html, body { width: 1200px; height: 630px; }
|
|
21
|
+
:root {
|
|
22
|
+
--bg: #101317; --fg: #d9dde3; --soft: #8b93a0; --accent: #8b83f0;
|
|
23
|
+
--rule: #262b32; --code-bg: #191d23;
|
|
24
|
+
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
|
25
|
+
--sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
26
|
+
}
|
|
27
|
+
body {
|
|
28
|
+
background: var(--bg);
|
|
29
|
+
color: var(--fg);
|
|
30
|
+
font-family: var(--sans);
|
|
31
|
+
-webkit-font-smoothing: antialiased;
|
|
32
|
+
position: relative;
|
|
33
|
+
overflow: hidden;
|
|
34
|
+
}
|
|
35
|
+
.frame {
|
|
36
|
+
position: absolute; inset: 64px 72px;
|
|
37
|
+
display: flex; flex-direction: column; justify-content: space-between;
|
|
38
|
+
}
|
|
39
|
+
.top { display: flex; align-items: center; gap: 28px; }
|
|
40
|
+
.glyph { width: 108px; height: 108px; flex: none; display: block; }
|
|
41
|
+
.wordmark {
|
|
42
|
+
font-family: var(--mono);
|
|
43
|
+
font-size: 68px;
|
|
44
|
+
font-weight: 600;
|
|
45
|
+
letter-spacing: -0.01em;
|
|
46
|
+
line-height: 1;
|
|
47
|
+
}
|
|
48
|
+
.tagline {
|
|
49
|
+
font-size: 40px;
|
|
50
|
+
line-height: 1.32;
|
|
51
|
+
max-width: 1010px;
|
|
52
|
+
font-weight: 400;
|
|
53
|
+
}
|
|
54
|
+
.tagline b { font-weight: 600; }
|
|
55
|
+
.foot {
|
|
56
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
57
|
+
font-family: var(--mono);
|
|
58
|
+
font-size: 24px;
|
|
59
|
+
color: var(--soft);
|
|
60
|
+
border-top: 1px solid var(--rule);
|
|
61
|
+
padding-top: 22px;
|
|
62
|
+
}
|
|
63
|
+
.foot .r { color: var(--accent); }
|
|
64
|
+
</style>
|
|
65
|
+
</head>
|
|
66
|
+
<body>
|
|
67
|
+
<div class="frame">
|
|
68
|
+
<div class="top">
|
|
69
|
+
<svg class="glyph" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
|
70
|
+
<rect width="32" height="32" rx="7" fill="#4f46c9"/>
|
|
71
|
+
<path d="M16 7h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-1v3l-3-3h-4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2z" fill="none" stroke="#fff" stroke-width="1.7" opacity="0.6"/>
|
|
72
|
+
<path d="M8 13h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4l-3 3v-3H8a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2z" fill="#fff"/>
|
|
73
|
+
</svg>
|
|
74
|
+
<div class="wordmark">FlowChat</div>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<div class="tagline">
|
|
78
|
+
Write a conversation as an ordinary Ruby method. FlowChat replays it across
|
|
79
|
+
stateless webhooks, on <b>USSD</b>, <b>WhatsApp</b>, <b>Telegram</b>, and
|
|
80
|
+
<b>HTTP</b>.
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<div class="foot">
|
|
84
|
+
<span>Conversational interfaces for Rails</span>
|
|
85
|
+
<span class="r">Radioactive Labs</span>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
</body>
|
|
89
|
+
</html>
|
data/site/favicon.svg
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
|
2
|
+
<rect width="32" height="32" rx="7" fill="#4f46c9"/>
|
|
3
|
+
<!-- two overlapping speech bubbles: a dialogue between two sides -->
|
|
4
|
+
<path d="M16 7h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-1v3l-3-3h-4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2z" fill="none" stroke="#fff" stroke-width="1.7" opacity="0.6"/>
|
|
5
|
+
<path d="M8 13h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4l-3 3v-3H8a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2z" fill="#fff"/>
|
|
6
|
+
</svg>
|
data/site/index.html
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>FlowChat: conversational interfaces for Rails</title>
|
|
7
|
+
<meta name="description" content="A Rails framework for building conversational flows that run across USSD, WhatsApp, Telegram, and HTTP. Write a conversation as an ordinary Ruby method; FlowChat replays it across stateless webhooks.">
|
|
8
|
+
<link rel="icon" href="favicon.svg" type="image/svg+xml">
|
|
9
|
+
<meta name="theme-color" content="#fdfdfc" media="(prefers-color-scheme: light)">
|
|
10
|
+
<meta name="theme-color" content="#101317" media="(prefers-color-scheme: dark)">
|
|
11
|
+
<meta property="og:type" content="website">
|
|
12
|
+
<meta property="og:title" content="FlowChat: conversational interfaces for Rails">
|
|
13
|
+
<meta property="og:description" content="Write a conversation as an ordinary Ruby method. FlowChat replays it across stateless webhooks, on every messaging channel: USSD, WhatsApp, Telegram, and HTTP.">
|
|
14
|
+
<meta property="og:url" content="https://radioactive-labs.github.io/flow_chat/">
|
|
15
|
+
<meta property="og:image" content="https://radioactive-labs.github.io/flow_chat/og.png">
|
|
16
|
+
<meta property="og:image:type" content="image/png">
|
|
17
|
+
<meta property="og:image:width" content="1200">
|
|
18
|
+
<meta property="og:image:height" content="630">
|
|
19
|
+
<meta property="og:image:alt" content="FlowChat, conversational interfaces for Rails: one flow across USSD, WhatsApp, Telegram, and HTTP.">
|
|
20
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
21
|
+
<meta name="twitter:title" content="FlowChat: conversational interfaces for Rails">
|
|
22
|
+
<meta name="twitter:description" content="Write a conversation as an ordinary Ruby method. FlowChat replays it across stateless webhooks, on every messaging channel.">
|
|
23
|
+
<meta name="twitter:image" content="https://radioactive-labs.github.io/flow_chat/og.png">
|
|
24
|
+
<script>
|
|
25
|
+
// Restore the saved theme before first paint, so there's no flash on refresh.
|
|
26
|
+
(function () {
|
|
27
|
+
try {
|
|
28
|
+
var t = localStorage.getItem("flowchat-theme");
|
|
29
|
+
if (t) document.documentElement.setAttribute("data-theme", t);
|
|
30
|
+
} catch (e) {}
|
|
31
|
+
})();
|
|
32
|
+
</script>
|
|
33
|
+
<style>
|
|
34
|
+
:root {
|
|
35
|
+
--bg: #fdfdfc;
|
|
36
|
+
--fg: #1c1f24;
|
|
37
|
+
--soft: #5b636e;
|
|
38
|
+
--link: #4038b0;
|
|
39
|
+
--code-bg: #f2f3f5;
|
|
40
|
+
--rule: #e3e5e8;
|
|
41
|
+
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
|
42
|
+
--sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
43
|
+
}
|
|
44
|
+
@media (prefers-color-scheme: dark) {
|
|
45
|
+
:root {
|
|
46
|
+
--bg: #101317; --fg: #d9dde3; --soft: #8b93a0; --link: #a9b2f8;
|
|
47
|
+
--code-bg: #191d23; --rule: #262b32;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
:root[data-theme="light"] { --bg:#fdfdfc; --fg:#1c1f24; --soft:#5b636e; --link:#4038b0; --code-bg:#f2f3f5; --rule:#e3e5e8; }
|
|
51
|
+
:root[data-theme="dark"] { --bg:#101317; --fg:#d9dde3; --soft:#8b93a0; --link:#a9b2f8; --code-bg:#191d23; --rule:#262b32; }
|
|
52
|
+
|
|
53
|
+
* { box-sizing: border-box; }
|
|
54
|
+
body {
|
|
55
|
+
margin: 0;
|
|
56
|
+
background: var(--bg);
|
|
57
|
+
color: var(--fg);
|
|
58
|
+
font-family: var(--sans);
|
|
59
|
+
font-size: 18px;
|
|
60
|
+
line-height: 1.62;
|
|
61
|
+
-webkit-font-smoothing: antialiased;
|
|
62
|
+
}
|
|
63
|
+
.page { max-width: 48rem; margin: 0 auto; padding: 4.5rem 1.5rem 5rem; position: relative; }
|
|
64
|
+
|
|
65
|
+
.theme-toggle {
|
|
66
|
+
position: absolute; top: 1.5rem; right: 1.5rem;
|
|
67
|
+
font-family: var(--mono); font-size: 0.74rem; color: var(--soft);
|
|
68
|
+
background: none; border: 0; cursor: pointer; padding: 0.2rem;
|
|
69
|
+
text-decoration: underline; text-underline-offset: 2px;
|
|
70
|
+
}
|
|
71
|
+
.theme-toggle:hover { color: var(--link); }
|
|
72
|
+
|
|
73
|
+
h1 {
|
|
74
|
+
font-family: var(--mono);
|
|
75
|
+
font-size: 1.5rem;
|
|
76
|
+
font-weight: 600;
|
|
77
|
+
letter-spacing: -0.01em;
|
|
78
|
+
margin: 0 0 1.4rem;
|
|
79
|
+
}
|
|
80
|
+
h2 {
|
|
81
|
+
font-size: 1.02rem;
|
|
82
|
+
font-weight: 600;
|
|
83
|
+
margin: 2.8rem 0 0.9rem;
|
|
84
|
+
padding-bottom: 0.4rem;
|
|
85
|
+
border-bottom: 1px solid var(--rule);
|
|
86
|
+
color: var(--fg);
|
|
87
|
+
}
|
|
88
|
+
p { margin: 0 0 1.1rem; }
|
|
89
|
+
.lede { font-size: 1.12rem; }
|
|
90
|
+
.sub { color: var(--soft); }
|
|
91
|
+
a { color: var(--link); text-decoration: underline; text-underline-offset: 2px; text-decoration-thickness: 1px; }
|
|
92
|
+
a:hover { text-decoration-thickness: 2px; }
|
|
93
|
+
|
|
94
|
+
.badges { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0.2rem 0 1.8rem; align-items: center; }
|
|
95
|
+
.badges a { display: inline-flex; text-decoration: none; }
|
|
96
|
+
.badges img { height: 20px; display: block; }
|
|
97
|
+
|
|
98
|
+
code {
|
|
99
|
+
font-family: var(--mono);
|
|
100
|
+
font-size: 0.86em;
|
|
101
|
+
background: var(--code-bg);
|
|
102
|
+
padding: 0.1em 0.35em;
|
|
103
|
+
border-radius: 3px;
|
|
104
|
+
}
|
|
105
|
+
pre {
|
|
106
|
+
background: var(--code-bg);
|
|
107
|
+
border: 1px solid var(--rule);
|
|
108
|
+
border-radius: 4px;
|
|
109
|
+
padding: 0.95rem 1.1rem;
|
|
110
|
+
overflow-x: auto;
|
|
111
|
+
margin: 0 0 1.1rem;
|
|
112
|
+
line-height: 1.55;
|
|
113
|
+
}
|
|
114
|
+
pre code { background: none; padding: 0; font-size: 0.8rem; }
|
|
115
|
+
.cmt { color: var(--soft); }
|
|
116
|
+
|
|
117
|
+
ul { margin: 0 0 1.1rem; padding-left: 1.2rem; }
|
|
118
|
+
li { margin-bottom: 0.5rem; }
|
|
119
|
+
li b { font-weight: 600; }
|
|
120
|
+
|
|
121
|
+
.links { list-style: none; padding: 0; margin: 0.4rem 0 0; }
|
|
122
|
+
.links li { margin-bottom: 0.55rem; display: flex; gap: 0.7rem; }
|
|
123
|
+
.links .what { color: var(--soft); min-width: 6.5rem; display: inline-block; }
|
|
124
|
+
|
|
125
|
+
footer {
|
|
126
|
+
margin-top: 3.2rem; padding-top: 1.1rem; border-top: 1px solid var(--rule);
|
|
127
|
+
font-family: var(--mono); font-size: 0.78rem; color: var(--soft);
|
|
128
|
+
}
|
|
129
|
+
:focus-visible { outline: 2px solid var(--link); outline-offset: 2px; }
|
|
130
|
+
</style>
|
|
131
|
+
</head>
|
|
132
|
+
<body>
|
|
133
|
+
<div class="page">
|
|
134
|
+
<button id="theme-toggle" class="theme-toggle" type="button">dark mode</button>
|
|
135
|
+
<h1>FlowChat</h1>
|
|
136
|
+
|
|
137
|
+
<p class="lede">Write a conversation as an ordinary Ruby method. FlowChat runs it across stateless webhooks, on every messaging channel.</p>
|
|
138
|
+
|
|
139
|
+
<p class="sub">FlowChat is a Rails framework for conversational interfaces. A USSD or chat webhook has no memory of the last message; FlowChat replaces the hand-rolled state machine with a session and a replay engine, so the same flow runs on USSD, WhatsApp, Telegram, and HTTP, with per-platform rendering. Ruby 3.0+.</p>
|
|
140
|
+
|
|
141
|
+
<p class="badges">
|
|
142
|
+
<a href="https://rubygems.org/gems/flow_chat"><img alt="Gem Version" src="https://badge.fury.io/rb/flow_chat.svg"></a>
|
|
143
|
+
<a href="https://github.com/radioactive-labs/flow_chat/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/radioactive-labs/flow_chat/actions/workflows/ci.yml/badge.svg?branch=master"></a>
|
|
144
|
+
<a href="https://www.ruby-lang.org/"><img alt="Ruby >= 3.0" src="https://img.shields.io/badge/ruby-%3E%3D%203.0-red.svg"></a>
|
|
145
|
+
<a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg"></a>
|
|
146
|
+
</p>
|
|
147
|
+
|
|
148
|
+
<h2>Install</h2>
|
|
149
|
+
<pre><code><span class="cmt"># Gemfile</span>
|
|
150
|
+
gem "flow_chat"</code></pre>
|
|
151
|
+
<pre><code>bundle install</code></pre>
|
|
152
|
+
<p class="sub">Set a cache for the session store, for example <code>FlowChat::Config.cache = Rails.cache</code>. No migrations, no generators.</p>
|
|
153
|
+
|
|
154
|
+
<h2>The 30-second tour</h2>
|
|
155
|
+
<p>A flow is a class of straight-line Ruby. Each <code>screen</code> is one step in the conversation:</p>
|
|
156
|
+
<pre><code>class RegistrationFlow < FlowChat::Flow
|
|
157
|
+
def main_page
|
|
158
|
+
name = app.screen(:name) { |prompt| prompt.ask "What's your name?" }
|
|
159
|
+
|
|
160
|
+
email = app.screen(:email) do |prompt|
|
|
161
|
+
prompt.ask "Your email?", validate: ->(input) { "Invalid email" unless input.include?("@") }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
app.say "Welcome #{name}!"
|
|
165
|
+
end
|
|
166
|
+
end</code></pre>
|
|
167
|
+
<p class="sub">FlowChat re-runs this method from the top on every webhook. Each <code>screen</code> returns its stored answer when the session has one and re-prompts when it does not, so the method reads as a synchronous script even though each turn is a separate request.</p>
|
|
168
|
+
|
|
169
|
+
<h2>What you get</h2>
|
|
170
|
+
<ul>
|
|
171
|
+
<li><b>One flow, every channel.</b> The same flow class runs on USSD, WhatsApp, Telegram, and HTTP. FlowChat renders numbered menus for USSD, reply buttons and lists for WhatsApp, inline keyboards for Telegram.</li>
|
|
172
|
+
<li><b>Stateful flows over stateless webhooks.</b> A replay engine reconstructs progress from the session, so you write linear Ruby with normal <code>if</code> and <code>case</code>, not a state machine.</li>
|
|
173
|
+
<li><b>Rich prompts.</b> <code>ask</code>, <code>select</code>, and <code>yes?</code>, with validation and transforms, and buttons or lists where the platform supports them.</li>
|
|
174
|
+
<li><b>Inbound and outbound media.</b> Read photos, location, and contacts through one <code>FlowChat::Input</code> value object, and send media back with a single <code>media:</code> option.</li>
|
|
175
|
+
<li><b>Flexible sessions.</b> Configurable boundaries, durable per-user sessions that survive USSD timeouts, and cross-platform or URL isolation.</li>
|
|
176
|
+
<li><b>Background processing.</b> Acknowledge the webhook and run the flow in a job, with a factory that builds one processor for both the webhook and the job.</li>
|
|
177
|
+
</ul>
|
|
178
|
+
|
|
179
|
+
<h2>Documentation</h2>
|
|
180
|
+
<ul class="links">
|
|
181
|
+
<li><span class="what">Guide & API</span><a href="https://github.com/radioactive-labs/flow_chat#readme">github.com/radioactive-labs/flow_chat</a></li>
|
|
182
|
+
<li><span class="what">Getting started</span><a href="https://github.com/radioactive-labs/flow_chat/blob/master/docs/getting-started.md">docs/getting-started.md</a></li>
|
|
183
|
+
<li><span class="what">Gem</span><a href="https://rubygems.org/gems/flow_chat">rubygems.org/gems/flow_chat</a></li>
|
|
184
|
+
</ul>
|
|
185
|
+
|
|
186
|
+
<footer>MIT License · <a href="https://radioactive-labs.github.io/">Radioactive Labs</a></footer>
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
<script>
|
|
190
|
+
(function () {
|
|
191
|
+
var root = document.documentElement;
|
|
192
|
+
var btn = document.getElementById("theme-toggle");
|
|
193
|
+
function effective() {
|
|
194
|
+
var t = root.getAttribute("data-theme");
|
|
195
|
+
if (t) return t;
|
|
196
|
+
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
197
|
+
}
|
|
198
|
+
function label() { btn.textContent = (effective() === "dark" ? "light" : "dark") + " mode"; }
|
|
199
|
+
btn.addEventListener("click", function () {
|
|
200
|
+
var next = effective() === "dark" ? "light" : "dark";
|
|
201
|
+
root.setAttribute("data-theme", next);
|
|
202
|
+
try { localStorage.setItem("flowchat-theme", next); } catch (e) {}
|
|
203
|
+
label();
|
|
204
|
+
});
|
|
205
|
+
label();
|
|
206
|
+
})();
|
|
207
|
+
</script>
|
|
208
|
+
</body>
|
|
209
|
+
</html>
|
data/site/og.png
ADDED
|
Binary file
|