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.
- checksums.yaml +4 -4
- data/.github/workflows/pages.yml +43 -0
- data/CHANGELOG.md +26 -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/lib/flow_chat/app.rb +129 -11
- 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/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 +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/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 +254 -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/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 +51 -3
- data/lib/flow_chat/whatsapp/id_generator.rb +0 -124
data/docs/gateway-development.md
CHANGED
|
@@ -1,315 +1,53 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Building a gateway
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
##
|
|
5
|
+
## The contract
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
def initialize(app, config)
|
|
14
|
+
def initialize(app, config = nil)
|
|
178
15
|
@app = app
|
|
179
|
-
@
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
context["request.
|
|
191
|
-
context["request.
|
|
192
|
-
context["request.
|
|
193
|
-
context["request.
|
|
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.
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
280
|
-
|
|
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
|
|
298
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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::
|
|
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
|
-
|
|
69
|
+
## Context values to set
|
|
381
70
|
|
|
382
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
88
|
+
## Adding platform middleware
|
|
411
89
|
|
|
412
|
-
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
517
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
+
## Supporting async
|
|
606
136
|
|
|
607
|
-
|
|
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
|
-
|
|
629
|
-
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
719
|
-
end
|
|
720
|
-
end
|
|
721
|
-
```
|
|
155
|
+
## Related
|
|
722
156
|
|
|
723
|
-
|
|
157
|
+
- [Architecture](architecture.md)
|
|
158
|
+
- [Gateway context variables](gateway-context-variables.md)
|
|
159
|
+
- [Async and background processing](async-background-processing.md)
|