flow_chat 0.9.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +29 -0
- data/.github/workflows/pages.yml +43 -0
- data/.gitignore +4 -1
- data/CHANGELOG.md +49 -0
- data/README.md +166 -295
- data/Rakefile +12 -1
- data/SECURITY.md +1 -1
- data/docs/architecture.md +52 -479
- data/docs/async-background-processing.md +31 -265
- data/docs/configuration.md +106 -613
- data/docs/factory-pattern.md +27 -324
- data/docs/gateway-context-variables.md +140 -143
- data/docs/gateway-development.md +86 -650
- data/docs/getting-started.md +40 -379
- data/docs/instrumentation.md +88 -279
- data/docs/platforms/instagram.md +278 -0
- data/docs/platforms/messenger.md +205 -0
- data/docs/platforms/telegram.md +47 -951
- data/docs/platforms/ussd.md +38 -653
- data/docs/platforms/whatsapp.md +73 -1321
- data/docs/superpowers/plans/2026-07-09-inbound-media-support.md +732 -0
- data/docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json +58 -0
- data/docs/superpowers/plans/2026-08-10-messenger-instagram.md +4064 -0
- data/docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json +226 -0
- data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md +972 -0
- data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json +88 -0
- data/docs/superpowers/specs/2026-07-09-inbound-media-support-design.md +195 -0
- data/docs/superpowers/specs/2026-08-10-messenger-instagram-design.md +391 -0
- data/docs/testing.md +26 -297
- data/examples/http_controller.rb +12 -10
- data/examples/intercom_configuration_example.rb +19 -24
- data/examples/intercom_controller.rb +8 -20
- data/examples/multi_tenant_whatsapp_controller.rb +61 -166
- data/examples/simulator_controller.rb +0 -1
- data/examples/ussd_controller.rb +86 -158
- data/examples/whatsapp_controller.rb +16 -15
- data/examples/whatsapp_media_examples.rb +27 -79
- data/flow_chat.gemspec +22 -2
- data/gemfiles/rails_main.gemfile +18 -0
- data/lib/flow_chat/app.rb +129 -11
- data/lib/flow_chat/async_job.rb +10 -0
- data/lib/flow_chat/choice_titles.rb +95 -0
- data/lib/flow_chat/config.rb +124 -1
- data/lib/flow_chat/delivery_error.rb +9 -0
- data/lib/flow_chat/gateway_async_support.rb +19 -1
- data/lib/flow_chat/http/configuration_error.rb +9 -0
- data/lib/flow_chat/http/gateway/simple.rb +24 -4
- data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
- data/lib/flow_chat/input.rb +86 -0
- data/lib/flow_chat/instagram/client.rb +32 -0
- data/lib/flow_chat/instagram/configuration.rb +147 -0
- data/lib/flow_chat/instagram/configuration_error.rb +7 -0
- data/lib/flow_chat/instagram/gateway/send_api.rb +63 -0
- data/lib/flow_chat/instagram/middleware/choice_mapper.rb +22 -0
- data/lib/flow_chat/instagram/renderer.rb +23 -0
- data/lib/flow_chat/instrumentation/metrics_collector.rb +6 -1
- data/lib/flow_chat/instrumentation.rb +160 -1
- data/lib/flow_chat/intercom/client.rb +34 -28
- data/lib/flow_chat/intercom/configuration.rb +2 -49
- data/lib/flow_chat/intercom/configuration_error.rb +9 -0
- data/lib/flow_chat/intercom/gateway/intercom_api.rb +81 -57
- data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
- data/lib/flow_chat/intercom/renderer.rb +57 -5
- data/lib/flow_chat/media.rb +121 -0
- data/lib/flow_chat/messenger/client.rb +264 -0
- data/lib/flow_chat/messenger/configuration.rb +103 -0
- data/lib/flow_chat/messenger/configuration_error.rb +9 -0
- data/lib/flow_chat/messenger/gateway/send_api.rb +42 -0
- data/lib/flow_chat/messenger/middleware/choice_mapper.rb +185 -0
- data/lib/flow_chat/messenger/renderer.rb +150 -0
- data/lib/flow_chat/meta/challenge.rb +24 -0
- data/lib/flow_chat/meta/choice_ladder.rb +37 -0
- data/lib/flow_chat/meta/configuration_error.rb +7 -0
- data/lib/flow_chat/meta/gateway_identity.rb +38 -0
- data/lib/flow_chat/meta/messaging_gateway.rb +471 -0
- data/lib/flow_chat/meta/signature.rb +30 -0
- data/lib/flow_chat/meta/signature_validation.rb +66 -0
- data/lib/flow_chat/meta/webhook_verification.rb +43 -0
- data/lib/flow_chat/named_configuration.rb +65 -0
- data/lib/flow_chat/prompt.rb +13 -16
- data/lib/flow_chat/renderers/markdown_support.rb +109 -0
- data/lib/flow_chat/security.rb +76 -0
- data/lib/flow_chat/session/middleware.rb +11 -2
- data/lib/flow_chat/simulator/controller.rb +31 -15
- data/lib/flow_chat/simulator/views/simulator.html.erb +184 -20
- data/lib/flow_chat/telegram/client.rb +47 -4
- data/lib/flow_chat/telegram/configuration.rb +2 -42
- data/lib/flow_chat/telegram/configuration_error.rb +9 -0
- data/lib/flow_chat/telegram/gateway/bot_api.rb +46 -28
- data/lib/flow_chat/telegram/middleware/choice_mapper.rb +77 -16
- data/lib/flow_chat/telegram/renderer.rb +10 -2
- data/lib/flow_chat/text_truncator.rb +75 -0
- data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
- data/lib/flow_chat/version.rb +1 -1
- data/lib/flow_chat/whatsapp/client.rb +14 -7
- data/lib/flow_chat/whatsapp/configuration.rb +12 -51
- data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
- data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +259 -203
- data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +144 -57
- data/lib/flow_chat/whatsapp/renderer.rb +121 -60
- data/lib/flow_chat.rb +0 -10
- data/lib/tasks/release.rake +11 -1
- 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 +60 -11
- data/lib/flow_chat/whatsapp/id_generator.rb +0 -124
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
|
metadata
CHANGED
|
@@ -1,31 +1,31 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: flow_chat
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.10.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stefan Froelich
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-08-17 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
|
-
name:
|
|
14
|
+
name: activesupport
|
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
|
16
16
|
requirements:
|
|
17
17
|
- - ">="
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: '
|
|
19
|
+
version: '6'
|
|
20
20
|
type: :runtime
|
|
21
21
|
prerelease: false
|
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
23
|
requirements:
|
|
24
24
|
- - ">="
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: '
|
|
26
|
+
version: '6'
|
|
27
27
|
- !ruby/object:Gem::Dependency
|
|
28
|
-
name:
|
|
28
|
+
name: actionpack
|
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
|
30
30
|
requirements:
|
|
31
31
|
- - ">="
|
|
@@ -39,19 +39,19 @@ dependencies:
|
|
|
39
39
|
- !ruby/object:Gem::Version
|
|
40
40
|
version: '6'
|
|
41
41
|
- !ruby/object:Gem::Dependency
|
|
42
|
-
name:
|
|
42
|
+
name: zeitwerk
|
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|
|
44
44
|
requirements:
|
|
45
45
|
- - ">="
|
|
46
46
|
- !ruby/object:Gem::Version
|
|
47
|
-
version: '
|
|
47
|
+
version: '0'
|
|
48
48
|
type: :runtime
|
|
49
49
|
prerelease: false
|
|
50
50
|
version_requirements: !ruby/object:Gem::Requirement
|
|
51
51
|
requirements:
|
|
52
52
|
- - ">="
|
|
53
53
|
- !ruby/object:Gem::Version
|
|
54
|
-
version: '
|
|
54
|
+
version: '0'
|
|
55
55
|
- !ruby/object:Gem::Dependency
|
|
56
56
|
name: phonelib
|
|
57
57
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -136,6 +136,7 @@ files:
|
|
|
136
136
|
- ".DS_Store"
|
|
137
137
|
- ".cliff.toml"
|
|
138
138
|
- ".github/workflows/ci.yml"
|
|
139
|
+
- ".github/workflows/pages.yml"
|
|
139
140
|
- ".github/workflows/release.yml"
|
|
140
141
|
- ".gitignore"
|
|
141
142
|
- ".ruby-version"
|
|
@@ -158,9 +159,19 @@ files:
|
|
|
158
159
|
- docs/gateway-development.md
|
|
159
160
|
- docs/getting-started.md
|
|
160
161
|
- docs/instrumentation.md
|
|
162
|
+
- docs/platforms/instagram.md
|
|
163
|
+
- docs/platforms/messenger.md
|
|
161
164
|
- docs/platforms/telegram.md
|
|
162
165
|
- docs/platforms/ussd.md
|
|
163
166
|
- docs/platforms/whatsapp.md
|
|
167
|
+
- docs/superpowers/plans/2026-07-09-inbound-media-support.md
|
|
168
|
+
- docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json
|
|
169
|
+
- docs/superpowers/plans/2026-08-10-messenger-instagram.md
|
|
170
|
+
- docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json
|
|
171
|
+
- docs/superpowers/plans/2026-08-16-unified-choice-resolution.md
|
|
172
|
+
- docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json
|
|
173
|
+
- docs/superpowers/specs/2026-07-09-inbound-media-support-design.md
|
|
174
|
+
- docs/superpowers/specs/2026-08-10-messenger-instagram-design.md
|
|
164
175
|
- docs/testing.md
|
|
165
176
|
- examples/custom_session_id_example.rb
|
|
166
177
|
- examples/http_controller.rb
|
|
@@ -172,31 +183,62 @@ files:
|
|
|
172
183
|
- examples/whatsapp_controller.rb
|
|
173
184
|
- examples/whatsapp_media_examples.rb
|
|
174
185
|
- flow_chat.gemspec
|
|
186
|
+
- gemfiles/rails_main.gemfile
|
|
175
187
|
- lib/flow_chat.rb
|
|
176
188
|
- lib/flow_chat/app.rb
|
|
177
189
|
- lib/flow_chat/async_job.rb
|
|
190
|
+
- lib/flow_chat/choice_titles.rb
|
|
178
191
|
- lib/flow_chat/config.rb
|
|
179
192
|
- lib/flow_chat/context.rb
|
|
193
|
+
- lib/flow_chat/delivery_error.rb
|
|
180
194
|
- lib/flow_chat/executor.rb
|
|
181
195
|
- lib/flow_chat/factory.rb
|
|
182
196
|
- lib/flow_chat/flow.rb
|
|
183
197
|
- lib/flow_chat/gateway_async_support.rb
|
|
184
198
|
- lib/flow_chat/generic_async_job.rb
|
|
199
|
+
- lib/flow_chat/http/configuration_error.rb
|
|
185
200
|
- lib/flow_chat/http/gateway/simple.rb
|
|
201
|
+
- lib/flow_chat/http/middleware/choice_mapper.rb
|
|
186
202
|
- lib/flow_chat/http/renderer.rb
|
|
203
|
+
- lib/flow_chat/input.rb
|
|
204
|
+
- lib/flow_chat/instagram/client.rb
|
|
205
|
+
- lib/flow_chat/instagram/configuration.rb
|
|
206
|
+
- lib/flow_chat/instagram/configuration_error.rb
|
|
207
|
+
- lib/flow_chat/instagram/gateway/send_api.rb
|
|
208
|
+
- lib/flow_chat/instagram/middleware/choice_mapper.rb
|
|
209
|
+
- lib/flow_chat/instagram/renderer.rb
|
|
187
210
|
- lib/flow_chat/instrumentation.rb
|
|
188
211
|
- lib/flow_chat/instrumentation/log_subscriber.rb
|
|
189
212
|
- lib/flow_chat/instrumentation/metrics_collector.rb
|
|
190
213
|
- lib/flow_chat/instrumentation/setup.rb
|
|
191
214
|
- lib/flow_chat/intercom/client.rb
|
|
192
215
|
- lib/flow_chat/intercom/configuration.rb
|
|
216
|
+
- lib/flow_chat/intercom/configuration_error.rb
|
|
193
217
|
- lib/flow_chat/intercom/gateway/intercom_api.rb
|
|
218
|
+
- lib/flow_chat/intercom/middleware/choice_mapper.rb
|
|
194
219
|
- lib/flow_chat/intercom/renderer.rb
|
|
195
220
|
- lib/flow_chat/interrupt.rb
|
|
221
|
+
- lib/flow_chat/media.rb
|
|
222
|
+
- lib/flow_chat/messenger/client.rb
|
|
223
|
+
- lib/flow_chat/messenger/configuration.rb
|
|
224
|
+
- lib/flow_chat/messenger/configuration_error.rb
|
|
225
|
+
- lib/flow_chat/messenger/gateway/send_api.rb
|
|
226
|
+
- lib/flow_chat/messenger/middleware/choice_mapper.rb
|
|
227
|
+
- lib/flow_chat/messenger/renderer.rb
|
|
228
|
+
- lib/flow_chat/meta/challenge.rb
|
|
229
|
+
- lib/flow_chat/meta/choice_ladder.rb
|
|
230
|
+
- lib/flow_chat/meta/configuration_error.rb
|
|
231
|
+
- lib/flow_chat/meta/gateway_identity.rb
|
|
232
|
+
- lib/flow_chat/meta/messaging_gateway.rb
|
|
233
|
+
- lib/flow_chat/meta/signature.rb
|
|
234
|
+
- lib/flow_chat/meta/signature_validation.rb
|
|
235
|
+
- lib/flow_chat/meta/webhook_verification.rb
|
|
236
|
+
- lib/flow_chat/named_configuration.rb
|
|
196
237
|
- lib/flow_chat/phone_number_util.rb
|
|
197
238
|
- lib/flow_chat/processor.rb
|
|
198
239
|
- lib/flow_chat/prompt.rb
|
|
199
240
|
- lib/flow_chat/renderers/markdown_support.rb
|
|
241
|
+
- lib/flow_chat/security.rb
|
|
200
242
|
- lib/flow_chat/session/cache_session_store.rb
|
|
201
243
|
- lib/flow_chat/session/middleware.rb
|
|
202
244
|
- lib/flow_chat/simulator/controller.rb
|
|
@@ -204,9 +246,11 @@ files:
|
|
|
204
246
|
- lib/flow_chat/telegram.rb
|
|
205
247
|
- lib/flow_chat/telegram/client.rb
|
|
206
248
|
- lib/flow_chat/telegram/configuration.rb
|
|
249
|
+
- lib/flow_chat/telegram/configuration_error.rb
|
|
207
250
|
- lib/flow_chat/telegram/gateway/bot_api.rb
|
|
208
251
|
- lib/flow_chat/telegram/middleware/choice_mapper.rb
|
|
209
252
|
- lib/flow_chat/telegram/renderer.rb
|
|
253
|
+
- lib/flow_chat/text_truncator.rb
|
|
210
254
|
- lib/flow_chat/ussd/gateway/nalo.rb
|
|
211
255
|
- lib/flow_chat/ussd/middleware/choice_mapper.rb
|
|
212
256
|
- lib/flow_chat/ussd/middleware/pagination.rb
|
|
@@ -214,12 +258,17 @@ files:
|
|
|
214
258
|
- lib/flow_chat/version.rb
|
|
215
259
|
- lib/flow_chat/whatsapp/client.rb
|
|
216
260
|
- lib/flow_chat/whatsapp/configuration.rb
|
|
261
|
+
- lib/flow_chat/whatsapp/configuration_error.rb
|
|
217
262
|
- lib/flow_chat/whatsapp/gateway/cloud_api.rb
|
|
218
|
-
- lib/flow_chat/whatsapp/id_generator.rb
|
|
219
263
|
- lib/flow_chat/whatsapp/middleware/choice_mapper.rb
|
|
220
264
|
- lib/flow_chat/whatsapp/renderer.rb
|
|
221
265
|
- lib/flow_chat/whatsapp/template_manager.rb
|
|
222
266
|
- lib/tasks/release.rake
|
|
267
|
+
- site/.nojekyll
|
|
268
|
+
- site/.og-card.html
|
|
269
|
+
- site/favicon.svg
|
|
270
|
+
- site/index.html
|
|
271
|
+
- site/og.png
|
|
223
272
|
homepage: https://github.com/radioactive-labs/flow_chat
|
|
224
273
|
licenses:
|
|
225
274
|
- MIT
|
|
@@ -237,7 +286,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
237
286
|
requirements:
|
|
238
287
|
- - ">="
|
|
239
288
|
- !ruby/object:Gem::Version
|
|
240
|
-
version:
|
|
289
|
+
version: '3.0'
|
|
241
290
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
242
291
|
requirements:
|
|
243
292
|
- - ">="
|
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
require "digest"
|
|
2
|
-
|
|
3
|
-
module FlowChat
|
|
4
|
-
module Whatsapp
|
|
5
|
-
# Generates WhatsApp-safe IDs from choice labels
|
|
6
|
-
#
|
|
7
|
-
# WhatsApp Cloud API requires button and list item IDs to be:
|
|
8
|
-
# - Maximum 256 characters
|
|
9
|
-
# - Unique within the message
|
|
10
|
-
# - Non-empty strings
|
|
11
|
-
#
|
|
12
|
-
# This generator:
|
|
13
|
-
# - Sanitizes labels to alphanumeric + underscore/hyphen
|
|
14
|
-
# - Truncates to fit within 256-char limit
|
|
15
|
-
# - Appends hash suffix for duplicate labels (similar to Rails index naming)
|
|
16
|
-
# - Ensures readability while maintaining uniqueness
|
|
17
|
-
#
|
|
18
|
-
# @example Basic usage
|
|
19
|
-
# generator = IdGenerator.new
|
|
20
|
-
# id = generator.generate_id("Create Account") # => "create_account"
|
|
21
|
-
#
|
|
22
|
-
# @example Handling duplicates
|
|
23
|
-
# generator = IdGenerator.new
|
|
24
|
-
# id1 = generator.generate_id("Accept") # => "accept"
|
|
25
|
-
# id2 = generator.generate_id("Accept") # => "accept_a1b2c3"
|
|
26
|
-
#
|
|
27
|
-
# @example Special characters
|
|
28
|
-
# generator = IdGenerator.new
|
|
29
|
-
# id = generator.generate_id("Yes! 👍 (recommended)") # => "yes_recommended"
|
|
30
|
-
#
|
|
31
|
-
class IdGenerator
|
|
32
|
-
MAX_ID_LENGTH = 256
|
|
33
|
-
HASH_SUFFIX_LENGTH = 3
|
|
34
|
-
|
|
35
|
-
def initialize
|
|
36
|
-
@generated_ids = []
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
# Generate a WhatsApp-safe ID from a label
|
|
40
|
-
#
|
|
41
|
-
# @param label [String] The choice label to convert
|
|
42
|
-
# @return [String] A sanitized, unique ID
|
|
43
|
-
def generate_id(label)
|
|
44
|
-
# Normalize the label
|
|
45
|
-
normalized = normalize_label(label)
|
|
46
|
-
|
|
47
|
-
# If normalized label is empty, use a fallback
|
|
48
|
-
normalized = "choice" if normalized.empty?
|
|
49
|
-
|
|
50
|
-
# Truncate to limit first
|
|
51
|
-
truncated = truncate_to_limit(normalized)
|
|
52
|
-
|
|
53
|
-
# Check if we need a hash suffix for uniqueness
|
|
54
|
-
final_id = if @generated_ids.include?(truncated)
|
|
55
|
-
add_hash_suffix(truncated, label)
|
|
56
|
-
else
|
|
57
|
-
truncated
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
# Track this ID
|
|
61
|
-
@generated_ids << final_id
|
|
62
|
-
|
|
63
|
-
final_id
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
# Reset the generator state (useful for testing or starting a new message)
|
|
67
|
-
def reset
|
|
68
|
-
@generated_ids = []
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
# Get all generated IDs (useful for debugging)
|
|
72
|
-
def generated_ids
|
|
73
|
-
@generated_ids.dup
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
private
|
|
77
|
-
|
|
78
|
-
# Normalize a label into a WhatsApp-safe identifier
|
|
79
|
-
# Keeps readability by preserving spaces and basic punctuation
|
|
80
|
-
#
|
|
81
|
-
# @param label [String] The original label
|
|
82
|
-
# @return [String] Normalized identifier
|
|
83
|
-
def normalize_label(label)
|
|
84
|
-
label
|
|
85
|
-
.to_s
|
|
86
|
-
.gsub(/[^\w\s\-']/, "") # remove special chars (keep word chars, spaces, hyphens, apostrophes)
|
|
87
|
-
.gsub(/\s+/, " ") # collapse multiple spaces to single space (after removing chars)
|
|
88
|
-
.strip # trim leading/trailing whitespace
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
# Add a hash suffix to make the ID unique
|
|
92
|
-
#
|
|
93
|
-
# The hash is generated from the original label to ensure
|
|
94
|
-
# the same label always produces the same hash.
|
|
95
|
-
#
|
|
96
|
-
# @param base_id [String] The base identifier
|
|
97
|
-
# @param original_label [String] The original label for hash generation
|
|
98
|
-
# @return [String] ID with hash suffix
|
|
99
|
-
def add_hash_suffix(base_id, original_label)
|
|
100
|
-
# Generate a short hash from the original label + timestamp for uniqueness
|
|
101
|
-
# We use the current generated_ids count to ensure different duplicates get different hashes
|
|
102
|
-
hash_input = "#{original_label}_#{@generated_ids.count { |id| id.start_with?(base_id) }}"
|
|
103
|
-
hash = Digest::SHA256.hexdigest(hash_input)[0...HASH_SUFFIX_LENGTH]
|
|
104
|
-
|
|
105
|
-
# Calculate max base length to fit: base + "_" + hash
|
|
106
|
-
max_base_length = MAX_ID_LENGTH - HASH_SUFFIX_LENGTH - 1
|
|
107
|
-
|
|
108
|
-
# Truncate base if needed
|
|
109
|
-
truncated_base = base_id[0...max_base_length]
|
|
110
|
-
|
|
111
|
-
"#{truncated_base} #{hash}"
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
# Truncate ID to maximum allowed length
|
|
115
|
-
#
|
|
116
|
-
# @param id [String] The ID to truncate
|
|
117
|
-
# @return [String] Truncated ID
|
|
118
|
-
def truncate_to_limit(id)
|
|
119
|
-
return id if id.length <= MAX_ID_LENGTH
|
|
120
|
-
id[0...MAX_ID_LENGTH]
|
|
121
|
-
end
|
|
122
|
-
end
|
|
123
|
-
end
|
|
124
|
-
end
|