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
@@ -1,315 +1,53 @@
1
- # Gateway Development Guide
1
+ # Building a gateway
2
2
 
3
- This guide shows how to build custom gateways to extend FlowChat to new platforms and services. FlowChat's pluggable architecture makes it easy to add support for SMS, Telegram, Slack, voice calls, or any other conversational platform.
3
+ A gateway adapts one messaging platform to FlowChat. It is the first and last layer of the middleware stack: it parses the platform's inbound webhook into normalized context values on the way in, and renders the flow's output back to the platform on the way out. FlowChat ships gateways for USSD (Nalo), WhatsApp, Telegram, HTTP, and Intercom; write your own to support anything else.
4
4
 
5
- ## Gateway Interface
5
+ ## The contract
6
6
 
7
- Every gateway must implement the basic interface:
8
-
9
- > **Note:** The examples in this guide are illustrative implementations to demonstrate the concepts. They are not part of the FlowChat codebase and would need additional work to be production-ready.
10
-
11
- ```ruby
12
- class YourCustomGateway
13
- # Required: Initialize with app and optional configuration
14
- def initialize(app, *config_args)
15
- @app = app
16
- @config = config_args.first
17
- end
18
-
19
- # Required: Process requests through the middleware stack
20
- def call(context)
21
- # 1. Parse platform-specific request
22
- parse_request(context)
23
-
24
- # 2. Process through FlowChat middleware stack
25
- type, prompt, choices, media = @app.call(context)
26
-
27
- # 3. Render platform-specific response
28
- render_response(type, prompt, choices, media, context)
29
- end
30
-
31
- # Optional: Configure platform-specific middleware
32
- def self.configure_middleware_stack(builder, custom_middleware)
33
- builder.use YourPlatform::SpecialMiddleware
34
- builder.use custom_middleware
35
- builder.use YourPlatform::ResponseMiddleware
36
- end
37
- end
38
- ```
39
-
40
- ## Example: Telegram Gateway (Hypothetical)
41
-
42
- Here's how you would build a complete Telegram Bot API gateway:
43
-
44
- ```ruby
45
- module MyCompany
46
- module Telegram
47
- module Gateway
48
- class BotAPI
49
- include FlowChat::Instrumentation
50
-
51
- def initialize(app, bot_token)
52
- @app = app
53
- @bot_token = bot_token
54
- @api_base = "https://api.telegram.org/bot#{@bot_token}"
55
- end
56
-
57
- def call(context)
58
- # 1. Parse Telegram webhook payload
59
- payload = parse_telegram_webhook(context)
60
-
61
- # 2. Set FlowChat context
62
- set_flowchat_context(context, payload)
63
-
64
- # 3. Process through middleware stack
65
- type, prompt, choices, media = @app.call(context)
66
-
67
- # 4. Send response via Telegram API
68
- send_telegram_response(type, prompt, choices, media, context)
69
- end
70
-
71
- # Optional: Configure Telegram-specific middleware
72
- def self.configure_middleware_stack(builder, custom_middleware)
73
- builder.use MyCompany::Telegram::Middleware::MessageProcessor
74
- builder.use custom_middleware
75
- builder.use MyCompany::Telegram::Middleware::ResponseFormatter
76
- end
77
-
78
- private
79
-
80
- def parse_telegram_webhook(context)
81
- body = context.controller.request.body.read
82
- JSON.parse(body)
83
- rescue JSON::ParserError => e
84
- Rails.logger.error "Telegram: Invalid JSON: #{e.message}"
85
- raise "Invalid webhook payload"
86
- end
87
-
88
- def set_flowchat_context(context, payload)
89
- message = payload.dig("message")
90
- callback_query = payload.dig("callback_query")
91
-
92
- if message
93
- # Regular message
94
- context["request.user_id"] = message.dig("from", "id").to_s
95
- context["request.message_id"] = message["message_id"].to_s
96
- context["request.platform"] = :telegram
97
- context["request.gateway"] = :telegram_bot_api
98
- context["request.timestamp"] = Time.at(message["date"]).iso8601
99
- context.input = message["text"]
100
-
101
- # Telegram-specific data
102
- context["telegram.chat_id"] = message.dig("chat", "id")
103
- context["telegram.username"] = message.dig("from", "username")
104
-
105
- elsif callback_query
106
- # Button press
107
- context["request.user_id"] = callback_query.dig("from", "id").to_s
108
- context["request.platform"] = :telegram
109
- context.input = callback_query["data"] # Button callback data
110
-
111
- context["telegram.chat_id"] = callback_query.dig("message", "chat", "id")
112
- context["telegram.callback_query_id"] = callback_query["id"]
113
- end
114
- end
115
-
116
- def send_telegram_response(type, prompt, choices, media, context)
117
- chat_id = context["telegram.chat_id"]
118
-
119
- response_data = {
120
- chat_id: chat_id,
121
- text: prompt
122
- }
123
-
124
- # Add inline keyboard for choices
125
- if choices.present?
126
- keyboard = build_inline_keyboard(choices)
127
- response_data[:reply_markup] = { inline_keyboard: keyboard }
128
- end
129
-
130
- # Send message via Telegram API
131
- send_telegram_api_request("sendMessage", response_data)
132
- end
133
-
134
- def build_inline_keyboard(choices)
135
- # Convert FlowChat choices to Telegram inline keyboard
136
- buttons = choices.map do |value, text|
137
- [{ text: text, callback_data: value }]
138
- end
139
- buttons
140
- end
141
-
142
- def send_telegram_api_request(method, data)
143
- uri = URI("#{@api_base}/#{method}")
144
-
145
- http = Net::HTTP.new(uri.host, uri.port)
146
- http.use_ssl = true
147
-
148
- request = Net::HTTP::Post.new(uri)
149
- request['Content-Type'] = 'application/json'
150
- request.body = data.to_json
151
-
152
- response = http.request(request)
153
-
154
- unless response.code.to_i.between?(200, 299)
155
- Rails.logger.error "Telegram API error: #{response.code} #{response.body}"
156
- end
157
-
158
- response
159
- end
160
- end
161
- end
162
- end
163
- end
164
- ```
165
-
166
- ## Example: SMS Gateway (Hypothetical)
167
-
168
- Here's how you would build an SMS gateway using Twilio:
7
+ A gateway is a middleware object. It takes the next app in its constructor and implements `call(context)`:
169
8
 
170
9
  ```ruby
171
10
  module MyCompany
172
11
  module Sms
173
12
  module Gateway
174
13
  class Twilio
175
- include FlowChat::Instrumentation
176
-
177
- def initialize(app, config)
14
+ def initialize(app, config = nil)
178
15
  @app = app
179
- @account_sid = config.account_sid
180
- @auth_token = config.auth_token
181
- @from_number = config.from_number
16
+ @config = config
182
17
  end
183
18
 
184
19
  def call(context)
185
- # Parse Twilio webhook
186
- params = context.controller.params
187
-
188
- # Set FlowChat context
189
- context["request.user_id"] = params["From"]
190
- context["request.msisdn"] = params["From"]
191
- context["request.message_id"] = params["MessageSid"]
192
- context["request.platform"] = :sms
193
- context["request.gateway"] = :twilio
20
+ @context = context
21
+ controller = context.controller
22
+ params = controller.request.params
23
+
24
+ # 1. Parse the inbound webhook into normalized context values.
25
+ context["request.id"] = params["MessageSid"]
26
+ context["request.msisdn"] = FlowChat::PhoneNumberUtil.to_e164(params["From"])
27
+ context["request.user_id"] = context["request.msisdn"]
28
+ context["request.message_id"] = SecureRandom.uuid
194
29
  context["request.timestamp"] = Time.current.iso8601
195
- context.input = params["Body"]
196
-
197
- # Process through middleware
198
- type, prompt, choices, media = @app.call(context)
199
-
200
- # Send SMS response
201
- send_sms(prompt, to: params["From"], context: context)
202
- end
203
-
204
- def self.configure_middleware_stack(builder, custom_middleware)
205
- # SMS-specific processing
206
- builder.use MyCompany::Sms::Middleware::CharacterLimitMiddleware
207
- builder.use custom_middleware
208
- builder.use MyCompany::Sms::Middleware::ChoiceFormatter
209
- end
210
-
211
- private
212
-
213
- def send_sms(message, to:, context:)
214
- # Format choices for SMS
215
- if context["choices"].present?
216
- choice_text = format_choices_for_sms(context["choices"])
217
- message = "#{message}\n\n#{choice_text}"
218
- end
219
-
220
- # Send via Twilio API
221
- twilio_client.messages.create(
222
- from: @from_number,
223
- to: to,
224
- body: message
225
- )
226
-
227
- # Instrument message sent
228
- instrument(FlowChat::Events::MESSAGE_SENT, {
229
- to: to,
230
- message: message,
231
- platform: :sms,
232
- gateway: :twilio,
233
- content_length: message.length,
234
- timestamp: context["request.timestamp"]
235
- })
236
- end
237
-
238
- def format_choices_for_sms(choices)
239
- choices.map.with_index(1) { |(value, text), i| "#{i}. #{text}" }.join("\n")
240
- end
241
-
242
- def twilio_client
243
- @twilio_client ||= ::Twilio::REST::Client.new(@account_sid, @auth_token)
244
- end
245
- end
246
- end
247
- end
248
- end
249
- ```
250
-
251
- ## Custom Middleware
30
+ context["request.gateway"] = :twilio
31
+ context["request.platform"] = :sms
32
+ context["request.body"] = params.to_h.transform_keys(&:to_s)
33
+ context.input = params["Body"].presence || ""
252
34
 
253
- Gateways can define their own middleware for platform-specific processing:
35
+ # 2. Run the rest of the stack. It returns [type, prompt, choices, media].
36
+ type, prompt, choices, _media = @app.call(context)
254
37
 
255
- ```ruby
256
- module MyCompany
257
- module Telegram
258
- module Middleware
259
- class MessageProcessor
260
- def initialize(app)
261
- @app = app
262
- end
263
-
264
- def call(context)
265
- # Pre-process Telegram-specific features
266
- handle_telegram_commands(context)
267
- handle_telegram_media(context)
268
-
269
- result = @app.call(context)
270
-
271
- # Post-process response
272
- format_telegram_response(context, result)
273
-
274
- result
38
+ # 3. Render the result back to the platform.
39
+ message = render(prompt, choices)
40
+ send_sms(message, to: context["request.msisdn"])
275
41
  end
276
42
 
277
43
  private
278
44
 
279
- def handle_telegram_commands(context)
280
- input = context.input
281
- return unless input&.start_with?('/')
282
-
283
- # Handle Telegram bot commands
284
- command = input.split.first
285
- context["telegram.command"] = command
286
-
287
- case command
288
- when "/start"
289
- context.input = nil # Start fresh conversation
290
- when "/help"
291
- context.input = "help"
292
- when "/cancel"
293
- context.input = "cancel"
294
- end
45
+ def render(prompt, choices)
46
+ # Turn prompt + choices into whatever the platform expects.
295
47
  end
296
48
 
297
- def handle_telegram_media(context)
298
- # Handle photos, documents, etc.
299
- # Implementation depends on your needs
300
- end
301
-
302
- def format_telegram_response(context, result)
303
- # Format response for Telegram's markdown
304
- type, prompt, choices, media = result
305
-
306
- if prompt.is_a?(String)
307
- # Escape special Telegram markdown characters
308
- prompt = prompt.gsub(/[_*\[\]()~`>#+=|{}.!-]/, '\\\\\&')
309
- result[1] = prompt
310
- end
311
-
312
- result
49
+ def send_sms(message, to:)
50
+ # Call the platform API.
313
51
  end
314
52
  end
315
53
  end
@@ -317,407 +55,105 @@ module MyCompany
317
55
  end
318
56
  ```
319
57
 
320
- ## Using Your Custom Gateway
58
+ `@app.call(context)` returns a four-element array: `[type, prompt, choices, media]`. `type` is `:prompt` while the conversation continues and `:terminal` when it has ended. `prompt` is the message text, `choices` is a hash of choice keys to labels (or `nil`), and `media` is an outbound media hash (or `nil`).
321
59
 
322
- Once built, use your gateway like any other:
60
+ Use the gateway with `use_gateway`, passing any constructor arguments after the class:
323
61
 
324
62
  ```ruby
325
- class TelegramController < ApplicationController
326
- def webhook
327
- processor = FlowChat::Processor.new(self) do |config|
328
- config.use_gateway MyCompany::Telegram::Gateway::BotAPI, telegram_bot_token
329
- config.use_session_store FlowChat::Session::CacheSessionStore
330
- config.use_durable_sessions # Use user_id for persistent sessions
331
- end
332
-
333
- processor.run WelcomeFlow, :start
334
- end
335
-
336
- private
337
-
338
- def telegram_bot_token
339
- ENV["TELEGRAM_BOT_TOKEN"]
340
- end
341
- end
342
- ```
343
-
344
- ## Gateway Configuration
345
-
346
- ### Configuration Objects
347
-
348
- Create configuration classes for complex gateways:
349
-
350
- ```ruby
351
- module MyCompany
352
- module Slack
353
- class Configuration
354
- attr_accessor :bot_token, :signing_secret, :app_token
355
- attr_accessor :default_channel, :enable_threads
356
-
357
- def initialize
358
- @enable_threads = true
359
- @default_channel = "#general"
360
- end
361
-
362
- def validate!
363
- raise "bot_token required" if bot_token.blank?
364
- raise "signing_secret required" if signing_secret.blank?
365
- end
366
- end
367
- end
368
- end
369
-
370
- # Usage
371
- slack_config = MyCompany::Slack::Configuration.new
372
- slack_config.bot_token = ENV["SLACK_BOT_TOKEN"]
373
- slack_config.signing_secret = ENV["SLACK_SIGNING_SECRET"]
374
-
375
63
  processor = FlowChat::Processor.new(self) do |config|
376
- config.use_gateway MyCompany::Slack::Gateway::BoltJS, slack_config
64
+ config.use_gateway MyCompany::Sms::Gateway::Twilio, sms_config
65
+ config.use_session_store FlowChat::Session::CacheSessionStore
377
66
  end
378
67
  ```
379
68
 
380
- ### Environment-Based Configuration
69
+ ## Context values to set
381
70
 
382
- ```ruby
383
- class MyCompany::Sms::Gateway::Twilio
384
- def initialize(app, config = nil)
385
- @app = app
386
-
387
- # Use config object or fall back to environment
388
- if config
389
- @account_sid = config.account_sid
390
- @auth_token = config.auth_token
391
- @from_number = config.from_number
392
- else
393
- @account_sid = ENV["TWILIO_ACCOUNT_SID"]
394
- @auth_token = ENV["TWILIO_AUTH_TOKEN"]
395
- @from_number = ENV["TWILIO_FROM_NUMBER"]
396
- end
397
-
398
- validate_configuration!
399
- end
71
+ The session middleware and `FlowChat::App` read normalized keys off the context. Set the ones your platform can provide:
400
72
 
401
- private
73
+ | Key | Purpose |
74
+ |---|---|
75
+ | `request.id` | The platform's session or conversation id. |
76
+ | `request.user_id` | A stable per-user id (used by `use_durable_sessions`). |
77
+ | `request.msisdn` | The user's phone number in E.164, when available. |
78
+ | `request.message_id` | A unique id for this message. |
79
+ | `request.timestamp` | ISO8601 time of the message. |
80
+ | `request.gateway` | Your gateway's symbol, for example `:twilio`. |
81
+ | `request.platform` | The platform symbol, for example `:sms`. |
82
+ | `request.body` | The raw request payload, with string keys. |
83
+ | `context.input` | The turn's text (a caption or `""` when the turn carries only an attachment). |
84
+ | `request.media` / `request.location` / `request.contact` | Structured attachments, when present. |
402
85
 
403
- def validate_configuration!
404
- required = [@account_sid, @auth_token, @from_number]
405
- raise "Twilio configuration incomplete" if required.any?(&:blank?)
406
- end
407
- end
408
- ```
86
+ The full list and how each existing gateway populates it is in [gateway-context-variables.md](gateway-context-variables.md).
409
87
 
410
- ## Testing Custom Gateways
88
+ ## Adding platform middleware
411
89
 
412
- ### Unit Testing
90
+ If your platform needs its own middleware (USSD adds pagination and choice-number mapping), define `self.configure_middleware_stack(builder, custom_middleware)`. The processor calls it in place of the default custom-middleware step, so you decide where your middleware and the user's sit:
413
91
 
414
92
  ```ruby
415
- # test/unit/telegram_gateway_test.rb
416
- class TelegramGatewayTest < Minitest::Test
417
- def setup
418
- @mock_app = proc { |context| [:text, "Test response", {}, nil] }
419
- @gateway = MyCompany::Telegram::Gateway::BotAPI.new(@mock_app, "test_token")
420
- end
421
-
422
- def test_parses_telegram_message
423
- context = create_mock_context(telegram_payload: {
424
- "message" => {
425
- "from" => { "id" => 12345, "username" => "testuser" },
426
- "text" => "Hello",
427
- "message_id" => 123
428
- }
429
- })
430
-
431
- @gateway.call(context)
432
-
433
- assert_equal "12345", context["request.user_id"]
434
- assert_equal :telegram, context["request.platform"]
435
- assert_equal "Hello", context.input
436
- end
437
-
438
- def test_handles_callback_queries
439
- context = create_mock_context(telegram_payload: {
440
- "callback_query" => {
441
- "from" => { "id" => 12345 },
442
- "data" => "button_value",
443
- "id" => "callback123"
444
- }
445
- })
446
-
447
- @gateway.call(context)
448
-
449
- assert_equal "button_value", context.input
450
- assert_equal "callback123", context["telegram.callback_query_id"]
451
- end
452
-
453
- private
454
-
455
- def create_mock_context(telegram_payload:)
456
- controller = Minitest::Mock.new
457
- request = Minitest::Mock.new
458
-
459
- request.expect :body, StringIO.new(telegram_payload.to_json)
460
- controller.expect :request, request
461
-
462
- context = FlowChat::Context.new
463
- context["controller"] = controller
464
- context
465
- end
93
+ def self.configure_middleware_stack(builder, custom_middleware)
94
+ builder.use MyCompany::Sms::Middleware::Segmentation
95
+ builder.use custom_middleware
466
96
  end
467
97
  ```
468
98
 
469
- ### Integration Testing
99
+ `custom_middleware` is the app's own custom-middleware builder. Include it, or the middleware a user added with `use_middleware` will not run.
470
100
 
471
- ```ruby
472
- # test/integration/telegram_flow_test.rb
473
- class TelegramFlowTest < ActionDispatch::IntegrationTest
474
- def test_complete_telegram_conversation
475
- # Simulate Telegram webhook for /start command
476
- post telegram_webhook_path,
477
- params: telegram_message_payload(text: "/start"),
478
- headers: telegram_headers
479
-
480
- assert_response :success
481
-
482
- # Simulate button press
483
- post telegram_webhook_path,
484
- params: telegram_callback_payload(data: "option_1"),
485
- headers: telegram_headers
486
-
487
- assert_response :success
488
- end
101
+ ## Writing a choice mapper
489
102
 
490
- private
491
-
492
- def telegram_message_payload(text:)
493
- {
494
- message: {
495
- from: { id: 12345, username: "testuser" },
496
- text: text,
497
- message_id: rand(1000),
498
- date: Time.current.to_i
499
- }
500
- }.to_json
501
- end
103
+ A choice mapper turns what the platform sends back into the key the flow branches on. Get one rule right and the rest follows:
502
104
 
503
- def telegram_headers
504
- {
505
- "Content-Type" => "application/json"
506
- }
507
- end
508
- end
509
- ```
105
+ > **Decide ambiguity under the same equivalence your resolver matches on.**
510
106
 
511
- ## Advanced Gateway Features
107
+ Every choice bug FlowChat has had came from breaking it — the resolver normalised input one way, and nothing checked whether two choices became indistinguishable under that normalisation, so one of them silently became unreachable.
512
108
 
513
- ### File Upload Support
109
+ `FlowChat::ChoiceTitles` enforces the rule for you. Pass it the `fold` your resolver applies and the `measure` your platform sizes fields in, and it hands back titles that are guaranteed distinct — numbering the whole set when they otherwise would not be:
514
110
 
515
111
  ```ruby
516
- def handle_file_upload(context, telegram_payload)
517
- message = telegram_payload["message"]
518
-
519
- if message["photo"]
520
- # Handle photo upload
521
- photo = message["photo"].last # Get highest resolution
522
- file_info = get_telegram_file(photo["file_id"])
523
-
524
- context["request.media"] = {
525
- type: "image",
526
- file_id: photo["file_id"],
527
- url: download_telegram_file(file_info["file_path"])
528
- }
529
-
530
- context.input = "$media$" # Special input to indicate media
531
-
532
- elsif message["document"]
533
- # Handle document upload
534
- doc = message["document"]
535
- context["request.media"] = {
536
- type: "document",
537
- file_id: doc["file_id"],
538
- filename: doc["file_name"],
539
- mime_type: doc["mime_type"]
540
- }
541
-
542
- context.input = "$document$"
543
- end
544
- end
112
+ FlowChat::ChoiceTitles.build(choices, title_cap, measure: :characters)
113
+ # => [[key, original_label, displayed_title, was_truncated], ...]
545
114
  ```
546
115
 
547
- ### Real-time Updates
116
+ Then make the displayed title the value you put on the wire. It is already unique within the set, so it needs no separate id space to be unique in — a tap sends it back as the payload, and a user who types what they read sends the same string, so one map resolves both.
548
117
 
549
- ```ruby
550
- class MyCompany::Slack::Gateway::BoltJS
551
- def call(context)
552
- # ... standard processing ...
553
-
554
- # Send typing indicator for long operations
555
- if processing_time_estimate > 2.seconds
556
- send_typing_indicator(context["slack.channel"])
557
- end
558
-
559
- # ... continue processing ...
560
- end
118
+ **The best fold is no fold, and today no mapper uses one:**
561
119
 
562
- private
120
+ | Mapper | Resolves on | Measure |
121
+ |---|---|---|
122
+ | WhatsApp, Messenger, Instagram | the displayed title, matched exactly | characters |
123
+ | Telegram | the displayed title, cut to `callback_data`'s limit | **bytes** |
124
+ | HTTP | the displayed title, matched exactly | characters |
125
+ | USSD, Intercom | the position printed beside each option | — |
563
126
 
564
- def send_typing_indicator(channel)
565
- slack_client.web_client.conversations_typing(channel: channel)
566
- end
567
- end
568
- ```
127
+ USSD and Intercom are the strongest form of the rule: positions are unique whatever the labels say, so their equivalence relation is already injective and there is nothing to check. If your platform prints a number and asks for one, do that and you need none of this. Intercom used to match labels case-insensitively as well, and that is exactly what made two options reading the same collapse onto one entry — the number beside them was already doing that job unambiguously.
569
128
 
570
- ### Multi-Message Responses
129
+ The rest match exactly, because a tapped payload and a client-echoed string are both produced by machines rather than typed. Every transform that could absorb a drift in those strings can also merge two choices, so none is worth adding on speculation. If you do add one — a platform where a person types freely might justify case folding — pass it as `fold:` so the ambiguity check uses it too, and expect more sets to be numbered as a result.
571
130
 
572
- ```ruby
573
- def send_slack_response(type, prompt, choices, media, context)
574
- channel = context["slack.channel"]
575
-
576
- # Send main message
577
- response = slack_client.web_client.chat_postMessage(
578
- channel: channel,
579
- text: prompt
580
- )
581
-
582
- # Send media as separate attachment if present
583
- if media.present?
584
- slack_client.web_client.files_upload(
585
- channels: channel,
586
- file: media[:url],
587
- title: media[:filename]
588
- )
589
- end
590
-
591
- # Add interactive buttons if choices present
592
- if choices.present?
593
- slack_client.web_client.chat_update(
594
- channel: channel,
595
- ts: response.ts,
596
- text: prompt,
597
- blocks: build_slack_blocks(choices)
598
- )
599
- end
600
- end
601
- ```
131
+ Where the number is already on screen — Intercom's numbered list, or the Meta `:numbered` rung — the mapper does not prefix anything, because the renderer is doing it. Prefixing in both places reads as `1. 1. Savings`.
602
132
 
603
- ## Best Practices
133
+ Number the set rather than disambiguating with a suffix. A position prefix sits at the front and survives truncation from the right, which is what makes it work even on a platform as tight as Telegram's 64 bytes; a suffix is the first thing a cut removes.
604
134
 
605
- ### Error Handling
135
+ ## Supporting async
606
136
 
607
- ```ruby
608
- def call(context)
609
- parse_request(context)
610
- type, prompt, choices, media = @app.call(context)
611
- render_response(type, prompt, choices, media, context)
612
- rescue JSON::ParserError => e
613
- Rails.logger.error "Gateway: Invalid JSON payload: #{e.message}"
614
- send_error_response("Invalid request format", context)
615
- rescue NetworkError => e
616
- Rails.logger.error "Gateway: Network error: #{e.message}"
617
- send_error_response("Service temporarily unavailable", context)
618
- rescue => e
619
- Rails.logger.error "Gateway: Unexpected error: #{e.class.name}: #{e.message}"
620
- Rails.logger.debug e.backtrace.join("\n")
621
- send_error_response("An error occurred", context)
622
- end
623
- ```
624
-
625
- ### Rate Limiting
137
+ Include `FlowChat::GatewayAsyncSupport` to let the gateway run flows in a background job. The concern provides `should_enqueue_async?` (true when async is enabled, the gateway supports it, and the request is not already running in the background) and `enqueue_async_job` (serializes the request and enqueues the job). Override `async_supported?` to return `false` on a synchronous protocol:
626
138
 
627
139
  ```ruby
628
- def call(context)
629
- check_rate_limit(context["request.user_id"])
630
- # ... continue processing ...
631
- end
632
-
633
- private
634
-
635
- def check_rate_limit(user_id)
636
- key = "rate_limit:#{user_id}"
637
- count = Rails.cache.increment(key, 1, expires_in: 1.minute) || 1
638
-
639
- if count > 30 # 30 requests per minute
640
- raise RateLimitExceeded, "Too many requests"
641
- end
642
- end
643
- ```
140
+ class Twilio
141
+ include FlowChat::GatewayAsyncSupport
644
142
 
645
- ### Security
646
-
647
- ```ruby
648
- def call(context)
649
- verify_webhook_signature(context)
650
- # ... continue processing ...
651
- end
652
-
653
- private
143
+ def call(context)
144
+ @context = context
145
+ @controller = context.controller
146
+ return if enqueue_async_job # enqueued; respond immediately
654
147
 
655
- def verify_webhook_signature(context)
656
- signature = context.controller.request.headers["X-Platform-Signature"]
657
- payload = context.controller.request.body.read
658
-
659
- expected = generate_signature(payload, @webhook_secret)
660
-
661
- unless secure_compare(signature, expected)
662
- raise SecurityError, "Invalid webhook signature"
148
+ # ... otherwise process inline as above
663
149
  end
664
150
  end
665
-
666
- def secure_compare(a, b)
667
- return false unless a.bytesize == b.bytesize
668
-
669
- l = a.unpack("C*")
670
- r = b.unpack("C*")
671
-
672
- l.zip(r).reduce(0) { |sum, (x, y)| sum | (x ^ y) } == 0
673
- end
674
- ```
675
-
676
- ## Publishing Your Gateway
677
-
678
- ### Gem Structure
679
-
680
- If you're building a reusable gateway, structure it as a gem:
681
-
682
151
  ```
683
- my_platform_gateway/
684
- ├── lib/
685
- │ └── flow_chat/
686
- │ └── my_platform/
687
- │ ├── gateway.rb
688
- │ ├── configuration.rb
689
- │ ├── middleware/
690
- │ └── renderer.rb
691
- ├── spec/
692
- ├── README.md
693
- └── my_platform_gateway.gemspec
694
- ```
695
-
696
- ### Documentation
697
-
698
- Document your gateway's:
699
- - Installation instructions
700
- - Configuration options
701
- - Platform-specific features
702
- - Middleware stack
703
- - Testing helpers
704
-
705
- ### Examples
706
-
707
- Provide working examples:
708
152
 
709
- ```ruby
710
- # examples/basic_controller.rb
711
- class MyPlatformController < ApplicationController
712
- def webhook
713
- processor = FlowChat::Processor.new(self) do |config|
714
- config.use_gateway FlowChat::MyPlatform::Gateway, platform_config
715
- config.use_session_store FlowChat::Session::CacheSessionStore
716
- end
153
+ `enqueue_async_job` returns `false` when async should not be used (for example when the request is already the background job), so you fall through to inline processing. See [async-background-processing.md](async-background-processing.md).
717
154
 
718
- processor.run WelcomeFlow, :start
719
- end
720
- end
721
- ```
155
+ ## Related
722
156
 
723
- Custom gateways make FlowChat incredibly powerful and flexible. With this foundation, you can integrate virtually any conversational platform into FlowChat's unified framework.
157
+ - [Architecture](architecture.md)
158
+ - [Gateway context variables](gateway-context-variables.md)
159
+ - [Async and background processing](async-background-processing.md)