flow_chat 0.8.2 → 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/.cliff.toml +74 -0
- data/.github/workflows/ci.yml +2 -3
- data/.github/workflows/pages.yml +43 -0
- data/.github/workflows/release.yml +56 -0
- data/.standard.yml +4 -0
- data/CHANGELOG.md +48 -0
- data/CLAUDE.md +327 -0
- data/CONTRIBUTING.md +134 -0
- data/Gemfile +1 -0
- data/README.md +189 -133
- data/Rakefile +17 -2
- data/SECURITY.md +42 -349
- data/docs/architecture.md +83 -0
- data/docs/async-background-processing.md +64 -0
- data/docs/configuration.md +110 -287
- data/docs/factory-pattern.md +58 -0
- data/docs/gateway-context-variables.md +168 -0
- data/docs/gateway-development.md +159 -0
- data/docs/getting-started.md +90 -0
- data/docs/instrumentation.md +95 -175
- data/docs/platforms/instagram.md +278 -0
- data/docs/platforms/messenger.md +205 -0
- data/docs/platforms/telegram.md +109 -0
- data/docs/platforms/ussd.md +78 -0
- data/docs/platforms/whatsapp.md +147 -0
- 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 +33 -426
- data/examples/custom_session_id_example.rb +119 -0
- data/examples/http_controller.rb +22 -20
- data/examples/intercom_configuration_example.rb +113 -0
- data/examples/intercom_controller.rb +182 -0
- data/examples/multi_tenant_whatsapp_controller.rb +63 -168
- data/examples/simulator_controller.rb +0 -1
- data/examples/ussd_controller.rb +88 -160
- data/examples/whatsapp_controller.rb +18 -17
- data/examples/whatsapp_media_examples.rb +27 -79
- data/flow_chat.gemspec +4 -0
- data/lib/flow_chat/app.rb +211 -0
- data/lib/flow_chat/async_job.rb +176 -0
- data/lib/flow_chat/choice_titles.rb +95 -0
- data/lib/flow_chat/config.rb +126 -23
- data/lib/flow_chat/delivery_error.rb +9 -0
- data/lib/flow_chat/{base_executor.rb → executor.rb} +6 -11
- 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/configuration_error.rb +9 -0
- data/lib/flow_chat/http/gateway/simple.rb +104 -36
- data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
- data/lib/flow_chat/http/renderer.rb +3 -3
- 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/setup.rb +1 -1
- data/lib/flow_chat/instrumentation.rb +182 -0
- data/lib/flow_chat/intercom/client.rb +161 -0
- data/lib/flow_chat/intercom/configuration.rb +102 -0
- data/lib/flow_chat/intercom/configuration_error.rb +9 -0
- data/lib/flow_chat/intercom/gateway/intercom_api.rb +420 -0
- data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
- data/lib/flow_chat/intercom/renderer.rb +123 -0
- 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/phone_number_util.rb +37 -35
- data/lib/flow_chat/processor.rb +188 -0
- data/lib/flow_chat/prompt.rb +13 -16
- data/lib/flow_chat/renderers/markdown_support.rb +167 -0
- data/lib/flow_chat/security.rb +76 -0
- data/lib/flow_chat/session/middleware.rb +36 -11
- 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 +283 -0
- data/lib/flow_chat/telegram/configuration.rb +78 -0
- data/lib/flow_chat/telegram/configuration_error.rb +9 -0
- data/lib/flow_chat/telegram/gateway/bot_api.rb +318 -0
- data/lib/flow_chat/telegram/middleware/choice_mapper.rb +96 -0
- data/lib/flow_chat/telegram/renderer.rb +133 -0
- data/lib/flow_chat/telegram.rb +7 -0
- data/lib/flow_chat/text_truncator.rb +75 -0
- data/lib/flow_chat/ussd/gateway/nalo.rb +24 -4
- data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
- 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 +158 -20
- data/lib/flow_chat/whatsapp/configuration.rb +13 -52
- data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
- data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +335 -248
- data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +234 -0
- data/lib/flow_chat/whatsapp/renderer.rb +259 -64
- data/lib/flow_chat.rb +1 -1
- data/lib/tasks/release.rake +165 -0
- 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 +132 -25
- data/docs/flows.md +0 -320
- data/docs/http-gateway-protocol.md +0 -432
- 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_app.rb +0 -86
- data/lib/flow_chat/base_processor.rb +0 -146
- data/lib/flow_chat/http/app.rb +0 -6
- data/lib/flow_chat/http/middleware/executor.rb +0 -24
- data/lib/flow_chat/http/processor.rb +0 -33
- 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 -96
- 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,9 @@
|
|
|
1
|
+
module FlowChat
|
|
2
|
+
module Telegram
|
|
3
|
+
# Its own file so Zeitwerk can resolve it by name. Declared inside
|
|
4
|
+
# gateway/bot_api.rb, which Zeitwerk maps to a different constant, it existed only
|
|
5
|
+
# once that file had loaded for some other reason, so an application
|
|
6
|
+
# rescuing it, or a test naming it first, got an uninitialized constant.
|
|
7
|
+
class ConfigurationError < StandardError; end
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module FlowChat
|
|
4
|
+
module Telegram
|
|
5
|
+
module Gateway
|
|
6
|
+
class BotApi
|
|
7
|
+
include FlowChat::Instrumentation
|
|
8
|
+
include FlowChat::GatewayAsyncSupport
|
|
9
|
+
|
|
10
|
+
attr_reader :client, :context
|
|
11
|
+
|
|
12
|
+
def initialize(app, config = nil)
|
|
13
|
+
@app = app
|
|
14
|
+
@config = config || FlowChat::Telegram::Configuration.from_credentials
|
|
15
|
+
@client = FlowChat::Telegram::Client.new(@config)
|
|
16
|
+
|
|
17
|
+
FlowChat.logger.info { "BotApi: Initialized Telegram Bot API gateway" }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(context)
|
|
21
|
+
@context = context
|
|
22
|
+
@controller = context.controller
|
|
23
|
+
request = @controller.request
|
|
24
|
+
|
|
25
|
+
FlowChat.logger.debug {
|
|
26
|
+
"BotApi: Processing #{begin
|
|
27
|
+
request.request_method
|
|
28
|
+
rescue
|
|
29
|
+
"POST"
|
|
30
|
+
end} request"
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# Handle POST webhooks only
|
|
34
|
+
if request.post?
|
|
35
|
+
return handle_webhook(context)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
@controller.head :bad_request
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Platform-specific middleware configuration
|
|
42
|
+
def self.configure_middleware_stack(builder, custom_middleware)
|
|
43
|
+
FlowChat.logger.debug { "BotApi: Configuring Telegram middleware stack" }
|
|
44
|
+
builder.use custom_middleware
|
|
45
|
+
builder.use FlowChat::Telegram::Middleware::ChoiceMapper
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def handle_webhook(context)
|
|
51
|
+
begin
|
|
52
|
+
parse_request_body(@controller.request)
|
|
53
|
+
FlowChat.logger.debug { "BotApi: Successfully parsed webhook request body" }
|
|
54
|
+
rescue JSON::ParserError => e
|
|
55
|
+
FlowChat.logger.error { "BotApi: Failed to parse webhook body: #{e.message}" }
|
|
56
|
+
return @controller.head :bad_request
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Validate webhook signature (skip in background)
|
|
60
|
+
# Return 200 OK even for invalid signatures to prevent Telegram from retrying
|
|
61
|
+
unless in_background? || valid_webhook_signature?(@controller.request)
|
|
62
|
+
FlowChat.logger.warn { "BotApi: Invalid webhook signature - dropping request" }
|
|
63
|
+
return @controller.head :ok
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Process update
|
|
67
|
+
if @body["message"]
|
|
68
|
+
process_message(@body["message"], context)
|
|
69
|
+
elsif @body["callback_query"]
|
|
70
|
+
process_callback_query(@body["callback_query"], context)
|
|
71
|
+
else
|
|
72
|
+
FlowChat.logger.debug { "BotApi: No message or callback_query in update - returning OK" }
|
|
73
|
+
return @controller.head :ok
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Routing: async or inline
|
|
77
|
+
if should_enqueue_async?
|
|
78
|
+
enqueue_async_job
|
|
79
|
+
return @controller.head :ok
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
handle_message_inline(context, @controller)
|
|
83
|
+
@controller.head :ok
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def process_message(message, context)
|
|
87
|
+
chat = message["chat"]
|
|
88
|
+
from = message["from"]
|
|
89
|
+
|
|
90
|
+
context["request.id"] = chat["id"].to_s
|
|
91
|
+
context["request.user_id"] = from["id"].to_s
|
|
92
|
+
context["request.user_name"] = [from["first_name"], from["last_name"]].compact.join(" ")
|
|
93
|
+
context["request.username"] = from["username"]
|
|
94
|
+
context["request.gateway"] = :telegram_bot_api
|
|
95
|
+
context["request.platform"] = :telegram
|
|
96
|
+
context["request.message_id"] = message["message_id"].to_s
|
|
97
|
+
context["request.timestamp"] = Time.at(message["date"]).iso8601
|
|
98
|
+
context["request.body"] = @body
|
|
99
|
+
|
|
100
|
+
context["telegram.client"] = @client
|
|
101
|
+
context["telegram.chat_type"] = chat["type"]
|
|
102
|
+
|
|
103
|
+
extract_message_content!(message, context)
|
|
104
|
+
|
|
105
|
+
if inbound_message?(context)
|
|
106
|
+
instrument(Events::MESSAGE_RECEIVED, {
|
|
107
|
+
from: from["id"].to_s,
|
|
108
|
+
message: context.input,
|
|
109
|
+
message_type: detect_message_type(message),
|
|
110
|
+
chat_type: chat["type"]
|
|
111
|
+
})
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def process_callback_query(callback_query, context)
|
|
116
|
+
from = callback_query["from"]
|
|
117
|
+
message = callback_query["message"]
|
|
118
|
+
chat = message["chat"]
|
|
119
|
+
|
|
120
|
+
context["request.id"] = chat["id"].to_s
|
|
121
|
+
context["request.user_id"] = from["id"].to_s
|
|
122
|
+
context["request.user_name"] = [from["first_name"], from["last_name"]].compact.join(" ")
|
|
123
|
+
context["request.username"] = from["username"]
|
|
124
|
+
context["request.gateway"] = :telegram_bot_api
|
|
125
|
+
context["request.platform"] = :telegram
|
|
126
|
+
context["request.message_id"] = message["message_id"].to_s
|
|
127
|
+
context["request.timestamp"] = Time.current.iso8601
|
|
128
|
+
context["request.body"] = @body
|
|
129
|
+
|
|
130
|
+
context["telegram.client"] = @client
|
|
131
|
+
context["telegram.callback_query_id"] = callback_query["id"]
|
|
132
|
+
context["telegram.original_message_id"] = message["message_id"]
|
|
133
|
+
context["telegram.chat_type"] = chat["type"]
|
|
134
|
+
|
|
135
|
+
# Input is the callback_data
|
|
136
|
+
context.input = callback_query["data"]
|
|
137
|
+
|
|
138
|
+
# Auto-answer callback query to remove loading indicator
|
|
139
|
+
@client.answer_callback_query(callback_query["id"])
|
|
140
|
+
|
|
141
|
+
instrument(Events::MESSAGE_RECEIVED, {
|
|
142
|
+
from: from["id"].to_s,
|
|
143
|
+
message: context.input,
|
|
144
|
+
message_type: "callback_query"
|
|
145
|
+
})
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def extract_message_content!(message, context)
|
|
149
|
+
if message["text"]
|
|
150
|
+
context.input = message["text"]
|
|
151
|
+
elsif message["location"]
|
|
152
|
+
context["request.location"] = {
|
|
153
|
+
"latitude" => message["location"]["latitude"],
|
|
154
|
+
"longitude" => message["location"]["longitude"]
|
|
155
|
+
}
|
|
156
|
+
context.input = ""
|
|
157
|
+
elsif message["photo"]
|
|
158
|
+
# Photos come as array, take highest resolution (last)
|
|
159
|
+
photo = message["photo"].last
|
|
160
|
+
context["request.media"] = {
|
|
161
|
+
type: :photo,
|
|
162
|
+
file_id: photo["file_id"],
|
|
163
|
+
file_unique_id: photo["file_unique_id"],
|
|
164
|
+
width: photo["width"],
|
|
165
|
+
height: photo["height"],
|
|
166
|
+
caption: message["caption"]
|
|
167
|
+
}
|
|
168
|
+
context.input = message["caption"].presence || ""
|
|
169
|
+
elsif message["video"]
|
|
170
|
+
video = message["video"]
|
|
171
|
+
context["request.media"] = {
|
|
172
|
+
type: :video,
|
|
173
|
+
file_id: video["file_id"],
|
|
174
|
+
file_unique_id: video["file_unique_id"],
|
|
175
|
+
width: video["width"],
|
|
176
|
+
height: video["height"],
|
|
177
|
+
duration: video["duration"],
|
|
178
|
+
mime_type: video["mime_type"],
|
|
179
|
+
caption: message["caption"]
|
|
180
|
+
}
|
|
181
|
+
context.input = message["caption"].presence || ""
|
|
182
|
+
elsif message["audio"]
|
|
183
|
+
audio = message["audio"]
|
|
184
|
+
context["request.media"] = {
|
|
185
|
+
type: :audio,
|
|
186
|
+
file_id: audio["file_id"],
|
|
187
|
+
file_unique_id: audio["file_unique_id"],
|
|
188
|
+
duration: audio["duration"],
|
|
189
|
+
mime_type: audio["mime_type"],
|
|
190
|
+
title: audio["title"],
|
|
191
|
+
performer: audio["performer"],
|
|
192
|
+
caption: message["caption"]
|
|
193
|
+
}
|
|
194
|
+
context.input = message["caption"].presence || ""
|
|
195
|
+
elsif message["document"]
|
|
196
|
+
doc = message["document"]
|
|
197
|
+
context["request.media"] = {
|
|
198
|
+
type: :document,
|
|
199
|
+
file_id: doc["file_id"],
|
|
200
|
+
file_unique_id: doc["file_unique_id"],
|
|
201
|
+
file_name: doc["file_name"],
|
|
202
|
+
mime_type: doc["mime_type"],
|
|
203
|
+
caption: message["caption"]
|
|
204
|
+
}
|
|
205
|
+
context.input = message["caption"].presence || ""
|
|
206
|
+
elsif message["voice"]
|
|
207
|
+
voice = message["voice"]
|
|
208
|
+
context["request.media"] = {
|
|
209
|
+
type: :voice,
|
|
210
|
+
file_id: voice["file_id"],
|
|
211
|
+
file_unique_id: voice["file_unique_id"],
|
|
212
|
+
duration: voice["duration"],
|
|
213
|
+
mime_type: voice["mime_type"],
|
|
214
|
+
caption: message["caption"]
|
|
215
|
+
}
|
|
216
|
+
context.input = message["caption"].presence || ""
|
|
217
|
+
elsif message["sticker"]
|
|
218
|
+
sticker = message["sticker"]
|
|
219
|
+
context["request.media"] = {
|
|
220
|
+
type: :sticker,
|
|
221
|
+
file_id: sticker["file_id"],
|
|
222
|
+
file_unique_id: sticker["file_unique_id"],
|
|
223
|
+
width: sticker["width"],
|
|
224
|
+
height: sticker["height"],
|
|
225
|
+
is_animated: sticker["is_animated"],
|
|
226
|
+
is_video: sticker["is_video"],
|
|
227
|
+
emoji: sticker["emoji"],
|
|
228
|
+
set_name: sticker["set_name"],
|
|
229
|
+
caption: message["caption"]
|
|
230
|
+
}
|
|
231
|
+
context.input = message["caption"].presence || ""
|
|
232
|
+
elsif message["contact"]
|
|
233
|
+
context["request.contact"] = {
|
|
234
|
+
phone_number: message["contact"]["phone_number"],
|
|
235
|
+
first_name: message["contact"]["first_name"],
|
|
236
|
+
last_name: message["contact"]["last_name"],
|
|
237
|
+
user_id: message["contact"]["user_id"]
|
|
238
|
+
}
|
|
239
|
+
context.input = ""
|
|
240
|
+
else
|
|
241
|
+
context.input = ""
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def detect_message_type(message)
|
|
246
|
+
return "text" if message["text"]
|
|
247
|
+
return "location" if message["location"]
|
|
248
|
+
return "photo" if message["photo"]
|
|
249
|
+
return "video" if message["video"]
|
|
250
|
+
return "audio" if message["audio"]
|
|
251
|
+
return "document" if message["document"]
|
|
252
|
+
return "voice" if message["voice"]
|
|
253
|
+
return "sticker" if message["sticker"]
|
|
254
|
+
return "contact" if message["contact"]
|
|
255
|
+
"unknown"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def valid_webhook_signature?(request)
|
|
259
|
+
return true if @config.skip_signature_validation
|
|
260
|
+
return true unless @config.secret_token
|
|
261
|
+
|
|
262
|
+
provided_token = request.headers["X-Telegram-Bot-Api-Secret-Token"]
|
|
263
|
+
return false unless provided_token
|
|
264
|
+
|
|
265
|
+
FlowChat::Security.secure_compare(@config.secret_token, provided_token.to_s)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def handle_message_inline(context, controller)
|
|
269
|
+
response = @app.call(context)
|
|
270
|
+
return unless response
|
|
271
|
+
|
|
272
|
+
_type, prompt, choices, media = response
|
|
273
|
+
result = report_delivery_failure(
|
|
274
|
+
context,
|
|
275
|
+
to: context["request.id"],
|
|
276
|
+
message: prompt,
|
|
277
|
+
gateway: :telegram_bot_api,
|
|
278
|
+
platform: :telegram
|
|
279
|
+
) do
|
|
280
|
+
@client.send_message(context["request.id"], prompt, choices: choices, media: media)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# report_delivery_failure already reported this; a nil result means
|
|
284
|
+
# the platform did not accept the message, and instrumenting
|
|
285
|
+
# MESSAGE_SENT anyway counted a delivery that never happened.
|
|
286
|
+
return unless result
|
|
287
|
+
|
|
288
|
+
instrument(Events::MESSAGE_SENT, {
|
|
289
|
+
to: context["request.id"],
|
|
290
|
+
message: prompt,
|
|
291
|
+
gateway: :telegram_bot_api,
|
|
292
|
+
platform: :telegram,
|
|
293
|
+
platform_message_id: platform_message_id_from(result),
|
|
294
|
+
duration_ms: context[FlowChat::Instrumentation::DELIVERY_DURATION_KEY]
|
|
295
|
+
})
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# The Bot API wraps every answer in an ok/result envelope.
|
|
299
|
+
def platform_message_id_from(result)
|
|
300
|
+
return nil unless result.is_a?(Hash)
|
|
301
|
+
|
|
302
|
+
result.dig("result", "message_id")
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def parse_request_body(request)
|
|
306
|
+
return @body if @body
|
|
307
|
+
|
|
308
|
+
if request.body.nil?
|
|
309
|
+
@body = {}
|
|
310
|
+
else
|
|
311
|
+
request.body.rewind if request.body.respond_to?(:rewind)
|
|
312
|
+
@body = JSON.parse(request.body.read)
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
module FlowChat
|
|
2
|
+
module Telegram
|
|
3
|
+
module Middleware
|
|
4
|
+
# Maps a Telegram reply back to the choice key the flow branches on.
|
|
5
|
+
#
|
|
6
|
+
# callback_data is 1-64 *bytes*, not characters, and carries no
|
|
7
|
+
# character restrictions - Telegram types it as a byte string. Two
|
|
8
|
+
# things followed from sizing it in characters instead:
|
|
9
|
+
#
|
|
10
|
+
# - a label with multibyte characters overflowed the field and was
|
|
11
|
+
# rejected by the API, because 64 characters of CJK or emoji is far
|
|
12
|
+
# more than 64 bytes;
|
|
13
|
+
# - two labels sharing their first 64 characters were cut to the same
|
|
14
|
+
# callback_data, which then matched neither key and failed the flow's
|
|
15
|
+
# own validation, so the choice could not be picked at all.
|
|
16
|
+
#
|
|
17
|
+
# Both are gone once the titles are built to a byte budget and a set
|
|
18
|
+
# that would collide under that budget is numbered. Numbering is what
|
|
19
|
+
# keeps them apart, and it works here for the same reason it works
|
|
20
|
+
# everywhere else: a position prefix sits at the front and survives a
|
|
21
|
+
# cut from the right, where a suffix would be the first thing lost.
|
|
22
|
+
class ChoiceMapper
|
|
23
|
+
SESSION_KEY = "telegram.choice_mapping"
|
|
24
|
+
POSITION_KEY = "telegram.position_mapping"
|
|
25
|
+
|
|
26
|
+
# https://core.telegram.org/bots/api#inlinekeyboardbutton
|
|
27
|
+
CALLBACK_DATA_LIMIT = 64
|
|
28
|
+
|
|
29
|
+
def initialize(app)
|
|
30
|
+
@app = app
|
|
31
|
+
FlowChat.logger.debug { "Telegram::ChoiceMapper: Initialized" }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call(context)
|
|
35
|
+
@context = context
|
|
36
|
+
@session = context.session
|
|
37
|
+
|
|
38
|
+
resolve_input
|
|
39
|
+
|
|
40
|
+
response = @app.call(context)
|
|
41
|
+
return response unless response
|
|
42
|
+
|
|
43
|
+
type, prompt, choices, media = response
|
|
44
|
+
choices = remember(choices) if choices.present?
|
|
45
|
+
|
|
46
|
+
[type, prompt, choices, media]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Titles first, then positions. A tap sends the title as its
|
|
52
|
+
# callback_data and a user typing what they read sends the same
|
|
53
|
+
# string, so both land on the same entry. A position must lose to a
|
|
54
|
+
# title, or a choice labelled "1" could never be picked.
|
|
55
|
+
def resolve_input
|
|
56
|
+
return if @session.nil? || @context.input.blank?
|
|
57
|
+
|
|
58
|
+
input = @context.input.to_s
|
|
59
|
+
resolved = mapping[input] || positions[input]
|
|
60
|
+
return unless resolved
|
|
61
|
+
|
|
62
|
+
FlowChat.logger.info { "Telegram::ChoiceMapper: Resolving #{input} to #{resolved}" }
|
|
63
|
+
@context.input = resolved
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def remember(choices)
|
|
67
|
+
return choices unless @session && choices.is_a?(Hash)
|
|
68
|
+
|
|
69
|
+
wire_choices = {}
|
|
70
|
+
choice_mapping = {}
|
|
71
|
+
|
|
72
|
+
titles = FlowChat::ChoiceTitles.build(choices, CALLBACK_DATA_LIMIT, measure: :bytes)
|
|
73
|
+
|
|
74
|
+
titles.each do |key, _label, title, _truncated|
|
|
75
|
+
wire_choices[title] = title
|
|
76
|
+
choice_mapping[title] = key
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
@session.set(SESSION_KEY, choice_mapping)
|
|
80
|
+
@session.set(POSITION_KEY, choices.keys.map.with_index(1) { |key, i| [i.to_s, key.to_s] }.to_h)
|
|
81
|
+
|
|
82
|
+
FlowChat.logger.debug { "Telegram::ChoiceMapper: Created mapping: #{choice_mapping}" }
|
|
83
|
+
wire_choices
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def mapping
|
|
87
|
+
@session.get(SESSION_KEY) || {}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def positions
|
|
91
|
+
@session.get(POSITION_KEY) || {}
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
require "flow_chat/renderers/markdown_support"
|
|
2
|
+
|
|
3
|
+
module FlowChat
|
|
4
|
+
module Telegram
|
|
5
|
+
class Renderer
|
|
6
|
+
include FlowChat::Renderers::MarkdownSupport
|
|
7
|
+
|
|
8
|
+
attr_reader :message, :choices, :media
|
|
9
|
+
|
|
10
|
+
def initialize(message, choices: nil, media: nil)
|
|
11
|
+
@message = message
|
|
12
|
+
@choices = choices
|
|
13
|
+
@media = media
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def render
|
|
17
|
+
if media && choices
|
|
18
|
+
build_media_with_keyboard
|
|
19
|
+
elsif media
|
|
20
|
+
build_media_message
|
|
21
|
+
elsif choices
|
|
22
|
+
build_keyboard_message
|
|
23
|
+
else
|
|
24
|
+
build_text_message
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def build_text_message
|
|
31
|
+
[:text, to_html(message), {}]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def build_keyboard_message
|
|
35
|
+
validate_choices!
|
|
36
|
+
keyboard = build_inline_keyboard(choices)
|
|
37
|
+
[:inline_keyboard, to_html(message), {keyboard: keyboard}]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def build_media_message
|
|
41
|
+
media_type = media[:type] || :photo
|
|
42
|
+
url = media[:url] || media[:file_id]
|
|
43
|
+
|
|
44
|
+
case media_type.to_sym
|
|
45
|
+
when :photo
|
|
46
|
+
[:photo, to_html(message), {url: url}]
|
|
47
|
+
when :document
|
|
48
|
+
[:document, to_html(message), {url: url, filename: media[:filename]}]
|
|
49
|
+
when :video
|
|
50
|
+
[:video, to_html(message), {url: url}]
|
|
51
|
+
when :audio
|
|
52
|
+
[:audio, to_html(message), {url: url}]
|
|
53
|
+
when :voice
|
|
54
|
+
[:voice, to_html(message), {url: url}]
|
|
55
|
+
else
|
|
56
|
+
# Fallback to text for unsupported types
|
|
57
|
+
[:text, to_html(message), {}]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def build_media_with_keyboard
|
|
62
|
+
validate_choices!
|
|
63
|
+
keyboard = build_inline_keyboard(choices)
|
|
64
|
+
media_type = media[:type] || :photo
|
|
65
|
+
url = media[:url] || media[:file_id]
|
|
66
|
+
|
|
67
|
+
[:photo_with_keyboard, to_html(message), {
|
|
68
|
+
url: url,
|
|
69
|
+
keyboard: keyboard,
|
|
70
|
+
media_type: media_type.to_sym
|
|
71
|
+
}]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# callback_data arrives already sized to 64 bytes by
|
|
75
|
+
# FlowChat::Telegram::Middleware::ChoiceMapper, which is also what made
|
|
76
|
+
# it distinct from its neighbours - so this cut is a no-op on that path
|
|
77
|
+
# and exists only for a renderer driven without the mapper.
|
|
78
|
+
#
|
|
79
|
+
# It measures bytes, because that is the unit Telegram sizes the field
|
|
80
|
+
# in. Slicing 64 *characters* both overflowed the field on multibyte
|
|
81
|
+
# labels and could merge two keys into one callback_data.
|
|
82
|
+
def build_inline_keyboard(choice_hash)
|
|
83
|
+
buttons = choice_hash.map do |key, value|
|
|
84
|
+
{
|
|
85
|
+
text: FlowChat::TextTruncator.truncate(value.to_s, 64, measure: :bytes),
|
|
86
|
+
callback_data: FlowChat::TextTruncator.truncate(key.to_s, 64, measure: :bytes)
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Layout: 2 buttons per row for <=4 choices, 1 per row for >4
|
|
91
|
+
if buttons.length <= 4
|
|
92
|
+
buttons.each_slice(2).to_a
|
|
93
|
+
else
|
|
94
|
+
buttons.zip
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def validate_choices!
|
|
99
|
+
unless choices.is_a?(Hash)
|
|
100
|
+
raise ArgumentError, "choices must be a Hash"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def truncate_text(text, length)
|
|
105
|
+
return text if text.length <= length
|
|
106
|
+
text[0, length - 3] + "..."
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# MarkdownSupport overrides for Telegram-specific behavior
|
|
110
|
+
|
|
111
|
+
def allowed_tags
|
|
112
|
+
# Tags supported by Telegram Bot API HTML mode
|
|
113
|
+
# Note: p and br are allowed through sanitization but converted to newlines in post_process_html
|
|
114
|
+
%w[b strong i em u s strike del a code pre blockquote p br]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def allowed_attributes
|
|
118
|
+
%w[href]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def post_process_html(html)
|
|
122
|
+
# Convert <p> tags to text with double newlines (Telegram doesn't support <p>)
|
|
123
|
+
result = html.gsub(%r{<p>(.*?)</p>}m, '\1' + "\n\n")
|
|
124
|
+
|
|
125
|
+
# Convert <br> and <br/> to newlines (Telegram doesn't support <br>)
|
|
126
|
+
result = result.gsub(/<br\s*\/?>/, "\n")
|
|
127
|
+
|
|
128
|
+
# Clean up excessive newlines
|
|
129
|
+
result.gsub(/\n{3,}/, "\n\n").strip
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
module FlowChat
|
|
2
|
+
# Shortens text to fit a display cap.
|
|
3
|
+
#
|
|
4
|
+
# Every renderer that fits a label into a platform's title limit needs this,
|
|
5
|
+
# and so does every choice mapper that has to know exactly what the user is
|
|
6
|
+
# looking at: a mapper that reimplemented the rule separately could drift
|
|
7
|
+
# from what the renderer actually renders, silently reopening the bug this
|
|
8
|
+
# class exists to prevent.
|
|
9
|
+
module TextTruncator
|
|
10
|
+
ELLIPSIS = "..."
|
|
11
|
+
|
|
12
|
+
# length is clamped to zero or more before anything else: String#[] with
|
|
13
|
+
# a negative second argument returns nil rather than raising, so a
|
|
14
|
+
# negative length (reachable from #number below, whose prefix can eat
|
|
15
|
+
# more than the whole cap at a small enough cap) would otherwise turn
|
|
16
|
+
# `nil + "..."` into a NoMethodError deep inside a render.
|
|
17
|
+
#
|
|
18
|
+
# Below 3 there is no room left for the ellipsis itself - it alone is 3
|
|
19
|
+
# characters - so a cap that small hard-truncates with no ellipsis
|
|
20
|
+
# rather than returning something longer than the cap it was supposed to
|
|
21
|
+
# respect.
|
|
22
|
+
# measure picks the unit the cap is expressed in. :bytes exists because
|
|
23
|
+
# Meta and Telegram both size these fields in bytes rather than
|
|
24
|
+
# characters - FlowChat::Instagram::Client#measure already draws the same
|
|
25
|
+
# distinction for message bodies, and for the same reason: a character
|
|
26
|
+
# count lets multibyte text through to be rejected by the platform.
|
|
27
|
+
def self.truncate(text, length, measure: :characters)
|
|
28
|
+
text = text.to_s
|
|
29
|
+
length = 0 if length.negative?
|
|
30
|
+
return text if size_of(text, measure) <= length
|
|
31
|
+
|
|
32
|
+
ellipsis = size_of(ELLIPSIS, measure)
|
|
33
|
+
return cut(text, length, measure) if length < ellipsis
|
|
34
|
+
|
|
35
|
+
cut(text, length - ellipsis, measure) + ELLIPSIS
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Prefixes text with its 1-based position on the rung ("1. ", "10. ",
|
|
39
|
+
# "100. ") and truncates the label to make room, so the combined string
|
|
40
|
+
# never exceeds cap. The prefix's length depends on position, so it has
|
|
41
|
+
# to be computed per choice rather than once for the whole rung: choice
|
|
42
|
+
# 10 loses one more character to its label than choice 1 does.
|
|
43
|
+
#
|
|
44
|
+
# Every renderer and choice mapper that numbers a choice's on-screen
|
|
45
|
+
# title shares this, for the same reason .truncate is shared: a mapper
|
|
46
|
+
# that reimplemented the prefix-and-truncate rule separately could drift
|
|
47
|
+
# from what the renderer actually renders, silently reopening the bug
|
|
48
|
+
# this class exists to prevent.
|
|
49
|
+
def self.number(text, position, cap, measure: :characters)
|
|
50
|
+
prefix = "#{position}. "
|
|
51
|
+
prefix + truncate(text.to_s, cap - size_of(prefix, measure), measure: measure)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.size_of(string, measure)
|
|
55
|
+
(measure == :bytes) ? string.bytesize : string.length
|
|
56
|
+
end
|
|
57
|
+
private_class_method :size_of
|
|
58
|
+
|
|
59
|
+
# Takes whole characters while they still fit the budget. Slicing bytes
|
|
60
|
+
# directly would cut a multi-byte sequence in half and hand the platform
|
|
61
|
+
# a string that is no longer valid UTF-8.
|
|
62
|
+
def self.cut(string, budget, measure)
|
|
63
|
+
return "" if budget <= 0
|
|
64
|
+
return string[0, budget] unless measure == :bytes
|
|
65
|
+
|
|
66
|
+
taken = +""
|
|
67
|
+
string.each_char do |char|
|
|
68
|
+
break if taken.bytesize + char.bytesize > budget
|
|
69
|
+
taken << char
|
|
70
|
+
end
|
|
71
|
+
taken
|
|
72
|
+
end
|
|
73
|
+
private_class_method :cut
|
|
74
|
+
end
|
|
75
|
+
end
|