flow_chat 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.cliff.toml +74 -0
- data/.github/workflows/ci.yml +2 -3
- data/.github/workflows/release.yml +56 -0
- data/.standard.yml +4 -0
- data/CHANGELOG.md +22 -0
- data/CLAUDE.md +327 -0
- data/CONTRIBUTING.md +134 -0
- data/Gemfile +1 -0
- data/README.md +290 -105
- data/Rakefile +5 -1
- data/SECURITY.md +42 -349
- data/docs/architecture.md +510 -0
- data/docs/async-background-processing.md +298 -0
- data/docs/configuration.md +556 -226
- data/docs/factory-pattern.md +355 -0
- data/docs/gateway-context-variables.md +171 -0
- data/docs/gateway-development.md +723 -0
- data/docs/getting-started.md +429 -0
- data/docs/instrumentation.md +264 -153
- data/docs/platforms/telegram.md +1013 -0
- data/docs/platforms/ussd.md +693 -0
- data/docs/platforms/whatsapp.md +1395 -0
- data/docs/testing.md +243 -365
- data/examples/custom_session_id_example.rb +119 -0
- data/examples/http_controller.rb +154 -0
- data/examples/intercom_configuration_example.rb +118 -0
- data/examples/intercom_controller.rb +194 -0
- data/examples/multi_tenant_whatsapp_controller.rb +4 -4
- data/examples/simulator_controller.rb +21 -1
- data/examples/ussd_controller.rb +10 -10
- data/examples/whatsapp_controller.rb +2 -2
- data/flow_chat.gemspec +4 -0
- data/lib/flow_chat/{base_app.rb → app.rb} +28 -9
- data/lib/flow_chat/async_job.rb +176 -0
- data/lib/flow_chat/config.rb +23 -28
- data/lib/flow_chat/{base_executor.rb → executor.rb} +7 -12
- data/lib/flow_chat/factory.rb +94 -0
- data/lib/flow_chat/gateway_async_support.rb +106 -0
- data/lib/flow_chat/generic_async_job.rb +30 -0
- data/lib/flow_chat/http/gateway/simple.rb +125 -0
- data/lib/flow_chat/http/renderer.rb +41 -0
- data/lib/flow_chat/instrumentation/setup.rb +1 -1
- data/lib/flow_chat/instrumentation.rb +25 -0
- data/lib/flow_chat/intercom/client.rb +155 -0
- data/lib/flow_chat/intercom/configuration.rb +149 -0
- data/lib/flow_chat/intercom/gateway/intercom_api.rb +396 -0
- data/lib/flow_chat/intercom/renderer.rb +71 -0
- data/lib/flow_chat/phone_number_util.rb +49 -0
- data/lib/flow_chat/processor.rb +188 -0
- data/lib/flow_chat/renderers/markdown_support.rb +58 -0
- data/lib/flow_chat/session/cache_session_store.rb +1 -17
- data/lib/flow_chat/session/middleware.rb +43 -26
- data/lib/flow_chat/simulator/controller.rb +17 -5
- data/lib/flow_chat/simulator/views/simulator.html.erb +220 -8
- data/lib/flow_chat/telegram/client.rb +240 -0
- data/lib/flow_chat/telegram/configuration.rb +118 -0
- data/lib/flow_chat/telegram/gateway/bot_api.rb +300 -0
- data/lib/flow_chat/telegram/middleware/choice_mapper.rb +35 -0
- data/lib/flow_chat/telegram/renderer.rb +125 -0
- data/lib/flow_chat/telegram.rb +7 -0
- data/lib/flow_chat/ussd/gateway/nalo.rb +27 -11
- data/lib/flow_chat/ussd/middleware/pagination.rb +9 -5
- data/lib/flow_chat/ussd/renderer.rb +1 -1
- data/lib/flow_chat/version.rb +1 -1
- data/lib/flow_chat/whatsapp/client.rb +144 -13
- data/lib/flow_chat/whatsapp/configuration.rb +1 -1
- data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +144 -106
- data/lib/flow_chat/whatsapp/id_generator.rb +124 -0
- data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +147 -0
- data/lib/flow_chat/whatsapp/renderer.rb +145 -11
- data/lib/flow_chat.rb +11 -1
- data/lib/tasks/release.rake +165 -0
- metadata +88 -21
- data/docs/flows.md +0 -320
- data/docs/images/simulator.png +0 -0
- data/docs/media.md +0 -153
- data/docs/sessions.md +0 -433
- data/docs/ussd-setup.md +0 -322
- data/docs/whatsapp-setup.md +0 -162
- data/examples/whatsapp_message_job.rb +0 -113
- data/lib/flow_chat/base_processor.rb +0 -145
- data/lib/flow_chat/session/rails_session_store.rb +0 -68
- data/lib/flow_chat/ussd/app.rb +0 -6
- data/lib/flow_chat/ussd/gateway/nsano.rb +0 -98
- data/lib/flow_chat/ussd/middleware/executor.rb +0 -24
- data/lib/flow_chat/ussd/processor.rb +0 -39
- data/lib/flow_chat/whatsapp/app.rb +0 -29
- data/lib/flow_chat/whatsapp/middleware/executor.rb +0 -24
- data/lib/flow_chat/whatsapp/processor.rb +0 -32
- data/lib/flow_chat/whatsapp/send_job_support.rb +0 -79
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
# Gateway Development Guide
|
|
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.
|
|
4
|
+
|
|
5
|
+
## Gateway Interface
|
|
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:
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
module MyCompany
|
|
172
|
+
module Sms
|
|
173
|
+
module Gateway
|
|
174
|
+
class Twilio
|
|
175
|
+
include FlowChat::Instrumentation
|
|
176
|
+
|
|
177
|
+
def initialize(app, config)
|
|
178
|
+
@app = app
|
|
179
|
+
@account_sid = config.account_sid
|
|
180
|
+
@auth_token = config.auth_token
|
|
181
|
+
@from_number = config.from_number
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
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
|
|
194
|
+
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
|
|
252
|
+
|
|
253
|
+
Gateways can define their own middleware for platform-specific processing:
|
|
254
|
+
|
|
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
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
private
|
|
278
|
+
|
|
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
|
|
295
|
+
end
|
|
296
|
+
|
|
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
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
## Using Your Custom Gateway
|
|
321
|
+
|
|
322
|
+
Once built, use your gateway like any other:
|
|
323
|
+
|
|
324
|
+
```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
|
+
processor = FlowChat::Processor.new(self) do |config|
|
|
376
|
+
config.use_gateway MyCompany::Slack::Gateway::BoltJS, slack_config
|
|
377
|
+
end
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
### Environment-Based Configuration
|
|
381
|
+
|
|
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
|
|
400
|
+
|
|
401
|
+
private
|
|
402
|
+
|
|
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
|
+
```
|
|
409
|
+
|
|
410
|
+
## Testing Custom Gateways
|
|
411
|
+
|
|
412
|
+
### Unit Testing
|
|
413
|
+
|
|
414
|
+
```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
|
|
466
|
+
end
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
### Integration Testing
|
|
470
|
+
|
|
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
|
|
489
|
+
|
|
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
|
|
502
|
+
|
|
503
|
+
def telegram_headers
|
|
504
|
+
{
|
|
505
|
+
"Content-Type" => "application/json"
|
|
506
|
+
}
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
## Advanced Gateway Features
|
|
512
|
+
|
|
513
|
+
### File Upload Support
|
|
514
|
+
|
|
515
|
+
```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
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
### Real-time Updates
|
|
548
|
+
|
|
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
|
|
561
|
+
|
|
562
|
+
private
|
|
563
|
+
|
|
564
|
+
def send_typing_indicator(channel)
|
|
565
|
+
slack_client.web_client.conversations_typing(channel: channel)
|
|
566
|
+
end
|
|
567
|
+
end
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
### Multi-Message Responses
|
|
571
|
+
|
|
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
|
+
```
|
|
602
|
+
|
|
603
|
+
## Best Practices
|
|
604
|
+
|
|
605
|
+
### Error Handling
|
|
606
|
+
|
|
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
|
|
626
|
+
|
|
627
|
+
```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
|
+
```
|
|
644
|
+
|
|
645
|
+
### Security
|
|
646
|
+
|
|
647
|
+
```ruby
|
|
648
|
+
def call(context)
|
|
649
|
+
verify_webhook_signature(context)
|
|
650
|
+
# ... continue processing ...
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
private
|
|
654
|
+
|
|
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"
|
|
663
|
+
end
|
|
664
|
+
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
|
+
```
|
|
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
|
+
|
|
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
|
|
717
|
+
|
|
718
|
+
processor.run WelcomeFlow, :start
|
|
719
|
+
end
|
|
720
|
+
end
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
Custom gateways make FlowChat incredibly powerful and flexible. With this foundation, you can integrate virtually any conversational platform into FlowChat's unified framework.
|