flow_chat 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +43 -0
  3. data/CHANGELOG.md +26 -0
  4. data/README.md +166 -295
  5. data/Rakefile +12 -1
  6. data/SECURITY.md +1 -1
  7. data/docs/architecture.md +52 -479
  8. data/docs/async-background-processing.md +31 -265
  9. data/docs/configuration.md +106 -613
  10. data/docs/factory-pattern.md +27 -324
  11. data/docs/gateway-context-variables.md +140 -143
  12. data/docs/gateway-development.md +86 -650
  13. data/docs/getting-started.md +40 -379
  14. data/docs/instrumentation.md +88 -279
  15. data/docs/platforms/instagram.md +278 -0
  16. data/docs/platforms/messenger.md +205 -0
  17. data/docs/platforms/telegram.md +47 -951
  18. data/docs/platforms/ussd.md +38 -653
  19. data/docs/platforms/whatsapp.md +73 -1321
  20. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md +732 -0
  21. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json +58 -0
  22. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md +4064 -0
  23. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json +226 -0
  24. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md +972 -0
  25. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json +88 -0
  26. data/docs/superpowers/specs/2026-07-09-inbound-media-support-design.md +195 -0
  27. data/docs/superpowers/specs/2026-08-10-messenger-instagram-design.md +391 -0
  28. data/docs/testing.md +26 -297
  29. data/examples/http_controller.rb +12 -10
  30. data/examples/intercom_configuration_example.rb +19 -24
  31. data/examples/intercom_controller.rb +8 -20
  32. data/examples/multi_tenant_whatsapp_controller.rb +61 -166
  33. data/examples/simulator_controller.rb +0 -1
  34. data/examples/ussd_controller.rb +86 -158
  35. data/examples/whatsapp_controller.rb +16 -15
  36. data/examples/whatsapp_media_examples.rb +27 -79
  37. data/lib/flow_chat/app.rb +129 -11
  38. data/lib/flow_chat/choice_titles.rb +95 -0
  39. data/lib/flow_chat/config.rb +124 -1
  40. data/lib/flow_chat/delivery_error.rb +9 -0
  41. data/lib/flow_chat/http/configuration_error.rb +9 -0
  42. data/lib/flow_chat/http/gateway/simple.rb +24 -4
  43. data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
  44. data/lib/flow_chat/input.rb +86 -0
  45. data/lib/flow_chat/instagram/client.rb +32 -0
  46. data/lib/flow_chat/instagram/configuration.rb +147 -0
  47. data/lib/flow_chat/instagram/configuration_error.rb +7 -0
  48. data/lib/flow_chat/instagram/gateway/send_api.rb +63 -0
  49. data/lib/flow_chat/instagram/middleware/choice_mapper.rb +22 -0
  50. data/lib/flow_chat/instagram/renderer.rb +23 -0
  51. data/lib/flow_chat/instrumentation/metrics_collector.rb +6 -1
  52. data/lib/flow_chat/instrumentation.rb +160 -1
  53. data/lib/flow_chat/intercom/client.rb +34 -28
  54. data/lib/flow_chat/intercom/configuration.rb +2 -49
  55. data/lib/flow_chat/intercom/configuration_error.rb +9 -0
  56. data/lib/flow_chat/intercom/gateway/intercom_api.rb +81 -57
  57. data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
  58. data/lib/flow_chat/intercom/renderer.rb +57 -5
  59. data/lib/flow_chat/media.rb +121 -0
  60. data/lib/flow_chat/messenger/client.rb +264 -0
  61. data/lib/flow_chat/messenger/configuration.rb +103 -0
  62. data/lib/flow_chat/messenger/configuration_error.rb +9 -0
  63. data/lib/flow_chat/messenger/gateway/send_api.rb +42 -0
  64. data/lib/flow_chat/messenger/middleware/choice_mapper.rb +185 -0
  65. data/lib/flow_chat/messenger/renderer.rb +150 -0
  66. data/lib/flow_chat/meta/challenge.rb +24 -0
  67. data/lib/flow_chat/meta/choice_ladder.rb +37 -0
  68. data/lib/flow_chat/meta/configuration_error.rb +7 -0
  69. data/lib/flow_chat/meta/gateway_identity.rb +38 -0
  70. data/lib/flow_chat/meta/messaging_gateway.rb +468 -0
  71. data/lib/flow_chat/meta/signature.rb +30 -0
  72. data/lib/flow_chat/meta/signature_validation.rb +66 -0
  73. data/lib/flow_chat/meta/webhook_verification.rb +43 -0
  74. data/lib/flow_chat/named_configuration.rb +65 -0
  75. data/lib/flow_chat/prompt.rb +13 -16
  76. data/lib/flow_chat/renderers/markdown_support.rb +109 -0
  77. data/lib/flow_chat/security.rb +76 -0
  78. data/lib/flow_chat/session/middleware.rb +11 -2
  79. data/lib/flow_chat/simulator/controller.rb +31 -15
  80. data/lib/flow_chat/simulator/views/simulator.html.erb +184 -20
  81. data/lib/flow_chat/telegram/client.rb +47 -4
  82. data/lib/flow_chat/telegram/configuration.rb +2 -42
  83. data/lib/flow_chat/telegram/configuration_error.rb +9 -0
  84. data/lib/flow_chat/telegram/gateway/bot_api.rb +46 -28
  85. data/lib/flow_chat/telegram/middleware/choice_mapper.rb +77 -16
  86. data/lib/flow_chat/telegram/renderer.rb +10 -2
  87. data/lib/flow_chat/text_truncator.rb +75 -0
  88. data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
  89. data/lib/flow_chat/version.rb +1 -1
  90. data/lib/flow_chat/whatsapp/client.rb +14 -7
  91. data/lib/flow_chat/whatsapp/configuration.rb +12 -51
  92. data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
  93. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +254 -203
  94. data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +144 -57
  95. data/lib/flow_chat/whatsapp/renderer.rb +121 -60
  96. data/lib/flow_chat.rb +0 -10
  97. data/site/.nojekyll +0 -0
  98. data/site/.og-card.html +89 -0
  99. data/site/favicon.svg +6 -0
  100. data/site/index.html +209 -0
  101. data/site/og.png +0 -0
  102. metadata +51 -3
  103. 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 &lt; 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: -&gt;(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 &amp; 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,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: flow_chat
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.10.0
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-07-09 00:00:00.000000000 Z
11
+ date: 2026-08-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: zeitwerk
@@ -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
@@ -175,28 +186,58 @@ files:
175
186
  - lib/flow_chat.rb
176
187
  - lib/flow_chat/app.rb
177
188
  - lib/flow_chat/async_job.rb
189
+ - lib/flow_chat/choice_titles.rb
178
190
  - lib/flow_chat/config.rb
179
191
  - lib/flow_chat/context.rb
192
+ - lib/flow_chat/delivery_error.rb
180
193
  - lib/flow_chat/executor.rb
181
194
  - lib/flow_chat/factory.rb
182
195
  - lib/flow_chat/flow.rb
183
196
  - lib/flow_chat/gateway_async_support.rb
184
197
  - lib/flow_chat/generic_async_job.rb
198
+ - lib/flow_chat/http/configuration_error.rb
185
199
  - lib/flow_chat/http/gateway/simple.rb
200
+ - lib/flow_chat/http/middleware/choice_mapper.rb
186
201
  - lib/flow_chat/http/renderer.rb
202
+ - lib/flow_chat/input.rb
203
+ - lib/flow_chat/instagram/client.rb
204
+ - lib/flow_chat/instagram/configuration.rb
205
+ - lib/flow_chat/instagram/configuration_error.rb
206
+ - lib/flow_chat/instagram/gateway/send_api.rb
207
+ - lib/flow_chat/instagram/middleware/choice_mapper.rb
208
+ - lib/flow_chat/instagram/renderer.rb
187
209
  - lib/flow_chat/instrumentation.rb
188
210
  - lib/flow_chat/instrumentation/log_subscriber.rb
189
211
  - lib/flow_chat/instrumentation/metrics_collector.rb
190
212
  - lib/flow_chat/instrumentation/setup.rb
191
213
  - lib/flow_chat/intercom/client.rb
192
214
  - lib/flow_chat/intercom/configuration.rb
215
+ - lib/flow_chat/intercom/configuration_error.rb
193
216
  - lib/flow_chat/intercom/gateway/intercom_api.rb
217
+ - lib/flow_chat/intercom/middleware/choice_mapper.rb
194
218
  - lib/flow_chat/intercom/renderer.rb
195
219
  - lib/flow_chat/interrupt.rb
220
+ - lib/flow_chat/media.rb
221
+ - lib/flow_chat/messenger/client.rb
222
+ - lib/flow_chat/messenger/configuration.rb
223
+ - lib/flow_chat/messenger/configuration_error.rb
224
+ - lib/flow_chat/messenger/gateway/send_api.rb
225
+ - lib/flow_chat/messenger/middleware/choice_mapper.rb
226
+ - lib/flow_chat/messenger/renderer.rb
227
+ - lib/flow_chat/meta/challenge.rb
228
+ - lib/flow_chat/meta/choice_ladder.rb
229
+ - lib/flow_chat/meta/configuration_error.rb
230
+ - lib/flow_chat/meta/gateway_identity.rb
231
+ - lib/flow_chat/meta/messaging_gateway.rb
232
+ - lib/flow_chat/meta/signature.rb
233
+ - lib/flow_chat/meta/signature_validation.rb
234
+ - lib/flow_chat/meta/webhook_verification.rb
235
+ - lib/flow_chat/named_configuration.rb
196
236
  - lib/flow_chat/phone_number_util.rb
197
237
  - lib/flow_chat/processor.rb
198
238
  - lib/flow_chat/prompt.rb
199
239
  - lib/flow_chat/renderers/markdown_support.rb
240
+ - lib/flow_chat/security.rb
200
241
  - lib/flow_chat/session/cache_session_store.rb
201
242
  - lib/flow_chat/session/middleware.rb
202
243
  - lib/flow_chat/simulator/controller.rb
@@ -204,9 +245,11 @@ files:
204
245
  - lib/flow_chat/telegram.rb
205
246
  - lib/flow_chat/telegram/client.rb
206
247
  - lib/flow_chat/telegram/configuration.rb
248
+ - lib/flow_chat/telegram/configuration_error.rb
207
249
  - lib/flow_chat/telegram/gateway/bot_api.rb
208
250
  - lib/flow_chat/telegram/middleware/choice_mapper.rb
209
251
  - lib/flow_chat/telegram/renderer.rb
252
+ - lib/flow_chat/text_truncator.rb
210
253
  - lib/flow_chat/ussd/gateway/nalo.rb
211
254
  - lib/flow_chat/ussd/middleware/choice_mapper.rb
212
255
  - lib/flow_chat/ussd/middleware/pagination.rb
@@ -214,12 +257,17 @@ files:
214
257
  - lib/flow_chat/version.rb
215
258
  - lib/flow_chat/whatsapp/client.rb
216
259
  - lib/flow_chat/whatsapp/configuration.rb
260
+ - lib/flow_chat/whatsapp/configuration_error.rb
217
261
  - lib/flow_chat/whatsapp/gateway/cloud_api.rb
218
- - lib/flow_chat/whatsapp/id_generator.rb
219
262
  - lib/flow_chat/whatsapp/middleware/choice_mapper.rb
220
263
  - lib/flow_chat/whatsapp/renderer.rb
221
264
  - lib/flow_chat/whatsapp/template_manager.rb
222
265
  - lib/tasks/release.rake
266
+ - site/.nojekyll
267
+ - site/.og-card.html
268
+ - site/favicon.svg
269
+ - site/index.html
270
+ - site/og.png
223
271
  homepage: https://github.com/radioactive-labs/flow_chat
224
272
  licenses:
225
273
  - MIT
@@ -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