flow_chat 0.9.0 → 0.10.1
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/ci.yml +29 -0
- data/.github/workflows/pages.yml +43 -0
- data/.gitignore +4 -1
- data/CHANGELOG.md +49 -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/flow_chat.gemspec +22 -2
- data/gemfiles/rails_main.gemfile +18 -0
- data/lib/flow_chat/app.rb +129 -11
- data/lib/flow_chat/async_job.rb +10 -0
- 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/gateway_async_support.rb +19 -1
- 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 +471 -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 +259 -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/lib/tasks/release.rake +11 -1
- 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 +60 -11
- data/lib/flow_chat/whatsapp/id_generator.rb +0 -124
|
@@ -179,6 +179,39 @@ module FlowChat
|
|
|
179
179
|
api_request("getMe")
|
|
180
180
|
end
|
|
181
181
|
|
|
182
|
+
# Get file metadata (including file_path) for an inbound file_id
|
|
183
|
+
def get_file(file_id)
|
|
184
|
+
api_request("getFile", {file_id: file_id})
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Build the download URL for an inbound file_id
|
|
188
|
+
def file_url(file_id)
|
|
189
|
+
# get_file answers nil when the API refused, so this cannot assume a
|
|
190
|
+
# hash back the way it did while every request returned its envelope.
|
|
191
|
+
file_path = get_file(file_id)&.dig("result", "file_path")
|
|
192
|
+
return nil unless file_path
|
|
193
|
+
|
|
194
|
+
"https://api.telegram.org/file/bot#{@config.bot_token}/#{file_path}"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Download the raw bytes for an inbound file_id
|
|
198
|
+
def download_file(file_id)
|
|
199
|
+
url = file_url(file_id)
|
|
200
|
+
return nil unless url
|
|
201
|
+
|
|
202
|
+
uri = URI(url)
|
|
203
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
204
|
+
http.use_ssl = true
|
|
205
|
+
response = http.get(uri.request_uri)
|
|
206
|
+
|
|
207
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
208
|
+
response.body
|
|
209
|
+
else
|
|
210
|
+
FlowChat.logger.error { "Telegram::Client: File download error: #{response.code}" }
|
|
211
|
+
nil
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
182
215
|
private
|
|
183
216
|
|
|
184
217
|
def api_request(method, params = {})
|
|
@@ -195,9 +228,7 @@ module FlowChat
|
|
|
195
228
|
response = http.request(request)
|
|
196
229
|
result = JSON.parse(response.body)
|
|
197
230
|
|
|
198
|
-
|
|
199
|
-
FlowChat.logger.debug { "Telegram::Client: API request successful" }
|
|
200
|
-
else
|
|
231
|
+
unless result["ok"]
|
|
201
232
|
FlowChat.logger.error { "Telegram::Client: API error - #{result["description"]}" }
|
|
202
233
|
report_api_error(
|
|
203
234
|
"Telegram API error: #{result["description"]}",
|
|
@@ -206,8 +237,17 @@ module FlowChat
|
|
|
206
237
|
error_description: result["description"],
|
|
207
238
|
chat_id: params[:chat_id]
|
|
208
239
|
)
|
|
240
|
+
|
|
241
|
+
# nil on a refused send, the contract every other client here keeps.
|
|
242
|
+
# Answering with the parsed error envelope instead made the failure
|
|
243
|
+
# indistinguishable from a success to anything upstream:
|
|
244
|
+
# report_delivery_failure tests the result for nil, so a Telegram
|
|
245
|
+
# send that Meta refused was reported as delivered and could never
|
|
246
|
+
# reach on_delivery_failure.
|
|
247
|
+
return nil
|
|
209
248
|
end
|
|
210
249
|
|
|
250
|
+
FlowChat.logger.debug { "Telegram::Client: API request successful" }
|
|
211
251
|
result
|
|
212
252
|
rescue Net::OpenTimeout, Net::ReadTimeout => network_error
|
|
213
253
|
FlowChat.logger.error { "Telegram::Client: Network timeout: #{network_error.class.name}: #{network_error.message}" }
|
|
@@ -220,7 +260,10 @@ module FlowChat
|
|
|
220
260
|
error: error,
|
|
221
261
|
chat_id: params[:chat_id]
|
|
222
262
|
)
|
|
223
|
-
|
|
263
|
+
|
|
264
|
+
# nil for the same reason a refused send answers nil above: an
|
|
265
|
+
# envelope here reads as a delivery to everything upstream.
|
|
266
|
+
nil
|
|
224
267
|
end
|
|
225
268
|
|
|
226
269
|
def report_api_error(message, api_method: nil, error_code: nil, error_description: nil, error: nil, chat_id: nil)
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
module FlowChat
|
|
2
2
|
module Telegram
|
|
3
3
|
class Configuration
|
|
4
|
-
|
|
4
|
+
include FlowChat::NamedConfiguration
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
attr_accessor :bot_token, :secret_token, :name, :skip_signature_validation
|
|
7
7
|
|
|
8
8
|
def initialize(name)
|
|
9
9
|
@name = name
|
|
@@ -43,46 +43,6 @@ module FlowChat
|
|
|
43
43
|
config
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
-
def self.register(name, config)
|
|
47
|
-
FlowChat.logger.debug { "Telegram::Configuration: Registering configuration '#{name}'" }
|
|
48
|
-
@@configurations[name.to_sym] = config
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def self.get(name)
|
|
52
|
-
config = @@configurations[name.to_sym]
|
|
53
|
-
if config
|
|
54
|
-
FlowChat.logger.debug { "Telegram::Configuration: Retrieved configuration '#{name}'" }
|
|
55
|
-
config
|
|
56
|
-
else
|
|
57
|
-
FlowChat.logger.error { "Telegram::Configuration: Configuration '#{name}' not found" }
|
|
58
|
-
raise ArgumentError, "Telegram configuration '#{name}' not found"
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def self.exists?(name)
|
|
63
|
-
exists = @@configurations.key?(name.to_sym)
|
|
64
|
-
FlowChat.logger.debug { "Telegram::Configuration: Configuration '#{name}' exists: #{exists}" }
|
|
65
|
-
exists
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def self.configuration_names
|
|
69
|
-
names = @@configurations.keys
|
|
70
|
-
FlowChat.logger.debug { "Telegram::Configuration: Available configurations: #{names}" }
|
|
71
|
-
names
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def self.clear_all!
|
|
75
|
-
FlowChat.logger.debug { "Telegram::Configuration: Clearing all registered configurations" }
|
|
76
|
-
@@configurations.clear
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def register_as(name)
|
|
80
|
-
FlowChat.logger.debug { "Telegram::Configuration: Registering configuration as '#{name}'" }
|
|
81
|
-
@name = name.to_sym
|
|
82
|
-
self.class.register(@name, self)
|
|
83
|
-
self
|
|
84
|
-
end
|
|
85
|
-
|
|
86
46
|
def valid?
|
|
87
47
|
is_valid = !!(bot_token && !bot_token.to_s.empty?)
|
|
88
48
|
FlowChat.logger.debug { "Telegram::Configuration: Configuration valid: #{is_valid}" }
|
|
@@ -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
|
|
@@ -2,8 +2,6 @@ require "json"
|
|
|
2
2
|
|
|
3
3
|
module FlowChat
|
|
4
4
|
module Telegram
|
|
5
|
-
class ConfigurationError < StandardError; end
|
|
6
|
-
|
|
7
5
|
module Gateway
|
|
8
6
|
class BotApi
|
|
9
7
|
include FlowChat::Instrumentation
|
|
@@ -104,7 +102,7 @@ module FlowChat
|
|
|
104
102
|
|
|
105
103
|
extract_message_content!(message, context)
|
|
106
104
|
|
|
107
|
-
if context
|
|
105
|
+
if inbound_message?(context)
|
|
108
106
|
instrument(Events::MESSAGE_RECEIVED, {
|
|
109
107
|
from: from["id"].to_s,
|
|
110
108
|
message: context.input,
|
|
@@ -155,7 +153,7 @@ module FlowChat
|
|
|
155
153
|
"latitude" => message["location"]["latitude"],
|
|
156
154
|
"longitude" => message["location"]["longitude"]
|
|
157
155
|
}
|
|
158
|
-
context.input =
|
|
156
|
+
context.input = ""
|
|
159
157
|
elsif message["photo"]
|
|
160
158
|
# Photos come as array, take highest resolution (last)
|
|
161
159
|
photo = message["photo"].last
|
|
@@ -164,9 +162,10 @@ module FlowChat
|
|
|
164
162
|
file_id: photo["file_id"],
|
|
165
163
|
file_unique_id: photo["file_unique_id"],
|
|
166
164
|
width: photo["width"],
|
|
167
|
-
height: photo["height"]
|
|
165
|
+
height: photo["height"],
|
|
166
|
+
caption: message["caption"]
|
|
168
167
|
}
|
|
169
|
-
context.input =
|
|
168
|
+
context.input = message["caption"].presence || ""
|
|
170
169
|
elsif message["video"]
|
|
171
170
|
video = message["video"]
|
|
172
171
|
context["request.media"] = {
|
|
@@ -176,9 +175,10 @@ module FlowChat
|
|
|
176
175
|
width: video["width"],
|
|
177
176
|
height: video["height"],
|
|
178
177
|
duration: video["duration"],
|
|
179
|
-
mime_type: video["mime_type"]
|
|
178
|
+
mime_type: video["mime_type"],
|
|
179
|
+
caption: message["caption"]
|
|
180
180
|
}
|
|
181
|
-
context.input =
|
|
181
|
+
context.input = message["caption"].presence || ""
|
|
182
182
|
elsif message["audio"]
|
|
183
183
|
audio = message["audio"]
|
|
184
184
|
context["request.media"] = {
|
|
@@ -188,9 +188,10 @@ module FlowChat
|
|
|
188
188
|
duration: audio["duration"],
|
|
189
189
|
mime_type: audio["mime_type"],
|
|
190
190
|
title: audio["title"],
|
|
191
|
-
performer: audio["performer"]
|
|
191
|
+
performer: audio["performer"],
|
|
192
|
+
caption: message["caption"]
|
|
192
193
|
}
|
|
193
|
-
context.input =
|
|
194
|
+
context.input = message["caption"].presence || ""
|
|
194
195
|
elsif message["document"]
|
|
195
196
|
doc = message["document"]
|
|
196
197
|
context["request.media"] = {
|
|
@@ -198,9 +199,10 @@ module FlowChat
|
|
|
198
199
|
file_id: doc["file_id"],
|
|
199
200
|
file_unique_id: doc["file_unique_id"],
|
|
200
201
|
file_name: doc["file_name"],
|
|
201
|
-
mime_type: doc["mime_type"]
|
|
202
|
+
mime_type: doc["mime_type"],
|
|
203
|
+
caption: message["caption"]
|
|
202
204
|
}
|
|
203
|
-
context.input =
|
|
205
|
+
context.input = message["caption"].presence || ""
|
|
204
206
|
elsif message["voice"]
|
|
205
207
|
voice = message["voice"]
|
|
206
208
|
context["request.media"] = {
|
|
@@ -208,9 +210,10 @@ module FlowChat
|
|
|
208
210
|
file_id: voice["file_id"],
|
|
209
211
|
file_unique_id: voice["file_unique_id"],
|
|
210
212
|
duration: voice["duration"],
|
|
211
|
-
mime_type: voice["mime_type"]
|
|
213
|
+
mime_type: voice["mime_type"],
|
|
214
|
+
caption: message["caption"]
|
|
212
215
|
}
|
|
213
|
-
context.input =
|
|
216
|
+
context.input = message["caption"].presence || ""
|
|
214
217
|
elsif message["sticker"]
|
|
215
218
|
sticker = message["sticker"]
|
|
216
219
|
context["request.media"] = {
|
|
@@ -222,9 +225,10 @@ module FlowChat
|
|
|
222
225
|
is_animated: sticker["is_animated"],
|
|
223
226
|
is_video: sticker["is_video"],
|
|
224
227
|
emoji: sticker["emoji"],
|
|
225
|
-
set_name: sticker["set_name"]
|
|
228
|
+
set_name: sticker["set_name"],
|
|
229
|
+
caption: message["caption"]
|
|
226
230
|
}
|
|
227
|
-
context.input =
|
|
231
|
+
context.input = message["caption"].presence || ""
|
|
228
232
|
elsif message["contact"]
|
|
229
233
|
context["request.contact"] = {
|
|
230
234
|
phone_number: message["contact"]["phone_number"],
|
|
@@ -232,7 +236,7 @@ module FlowChat
|
|
|
232
236
|
last_name: message["contact"]["last_name"],
|
|
233
237
|
user_id: message["contact"]["user_id"]
|
|
234
238
|
}
|
|
235
|
-
context.input =
|
|
239
|
+
context.input = ""
|
|
236
240
|
else
|
|
237
241
|
context.input = ""
|
|
238
242
|
end
|
|
@@ -258,15 +262,7 @@ module FlowChat
|
|
|
258
262
|
provided_token = request.headers["X-Telegram-Bot-Api-Secret-Token"]
|
|
259
263
|
return false unless provided_token
|
|
260
264
|
|
|
261
|
-
secure_compare(@config.secret_token, provided_token.to_s)
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
def secure_compare(a, b)
|
|
265
|
-
return false unless a.bytesize == b.bytesize
|
|
266
|
-
l = a.unpack("C*")
|
|
267
|
-
res = 0
|
|
268
|
-
b.each_byte { |byte| res |= byte ^ l.shift }
|
|
269
|
-
res == 0
|
|
265
|
+
FlowChat::Security.secure_compare(@config.secret_token, provided_token.to_s)
|
|
270
266
|
end
|
|
271
267
|
|
|
272
268
|
def handle_message_inline(context, controller)
|
|
@@ -274,16 +270,38 @@ module FlowChat
|
|
|
274
270
|
return unless response
|
|
275
271
|
|
|
276
272
|
_type, prompt, choices, media = response
|
|
277
|
-
|
|
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
|
|
278
287
|
|
|
279
288
|
instrument(Events::MESSAGE_SENT, {
|
|
280
289
|
to: context["request.id"],
|
|
281
290
|
message: prompt,
|
|
282
291
|
gateway: :telegram_bot_api,
|
|
283
|
-
platform: :telegram
|
|
292
|
+
platform: :telegram,
|
|
293
|
+
platform_message_id: platform_message_id_from(result),
|
|
294
|
+
duration_ms: context[FlowChat::Instrumentation::DELIVERY_DURATION_KEY]
|
|
284
295
|
})
|
|
285
296
|
end
|
|
286
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
|
+
|
|
287
305
|
def parse_request_body(request)
|
|
288
306
|
return @body if @body
|
|
289
307
|
|
|
@@ -1,33 +1,94 @@
|
|
|
1
1
|
module FlowChat
|
|
2
2
|
module Telegram
|
|
3
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.
|
|
4
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
|
+
|
|
5
29
|
def initialize(app)
|
|
6
30
|
@app = app
|
|
31
|
+
FlowChat.logger.debug { "Telegram::ChoiceMapper: Initialized" }
|
|
7
32
|
end
|
|
8
33
|
|
|
9
34
|
def call(context)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
choices = context.session.get("telegram_choices")
|
|
15
|
-
if choices&.key?(input)
|
|
16
|
-
FlowChat.logger.debug { "ChoiceMapper: Input '#{input}' is a valid choice key" }
|
|
17
|
-
end
|
|
18
|
-
end
|
|
35
|
+
@context = context
|
|
36
|
+
@session = context.session
|
|
37
|
+
|
|
38
|
+
resolve_input
|
|
19
39
|
|
|
20
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
|
|
21
61
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
28
77
|
end
|
|
29
78
|
|
|
30
|
-
|
|
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) || {}
|
|
31
92
|
end
|
|
32
93
|
end
|
|
33
94
|
end
|
|
@@ -71,11 +71,19 @@ module FlowChat
|
|
|
71
71
|
}]
|
|
72
72
|
end
|
|
73
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.
|
|
74
82
|
def build_inline_keyboard(choice_hash)
|
|
75
83
|
buttons = choice_hash.map do |key, value|
|
|
76
84
|
{
|
|
77
|
-
text:
|
|
78
|
-
callback_data: key.to_s
|
|
85
|
+
text: FlowChat::TextTruncator.truncate(value.to_s, 64, measure: :bytes),
|
|
86
|
+
callback_data: FlowChat::TextTruncator.truncate(key.to_s, 64, measure: :bytes)
|
|
79
87
|
}
|
|
80
88
|
end
|
|
81
89
|
|
|
@@ -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
|
|
@@ -57,6 +57,16 @@ module FlowChat
|
|
|
57
57
|
@context.input = original_choice
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
+
# USSD is deliberately exempt from the fold every other choice mapper
|
|
61
|
+
# now takes. A numeric keypad can only ever send a position, and
|
|
62
|
+
# positions are unique by construction, so the relation this resolves
|
|
63
|
+
# on is already injective - there is no equivalence under which two
|
|
64
|
+
# choices could collapse into one another.
|
|
65
|
+
#
|
|
66
|
+
# Every other mapper has to be *given* that guarantee, by asking
|
|
67
|
+
# FlowChat::ChoiceTitles to number a set whose titles collide under
|
|
68
|
+
# its own resolver's fold. Nothing here needs to, and adding aliasing
|
|
69
|
+
# to this mapper would introduce the very ambiguity it does not have.
|
|
60
70
|
def create_numbered_mapping(choices)
|
|
61
71
|
# Choices are always a hash after normalize_choices
|
|
62
72
|
numbered_choices = {}
|
data/lib/flow_chat/version.rb
CHANGED
|
@@ -25,14 +25,21 @@ module FlowChat
|
|
|
25
25
|
|
|
26
26
|
# Use renderer to convert to structured response
|
|
27
27
|
response = FlowChat::Whatsapp::Renderer.new(prompt, choices: choices, media: media).render
|
|
28
|
-
|
|
28
|
+
_, _, options = response
|
|
29
|
+
|
|
30
|
+
# MESSAGE_SENT is instrumented by the gateway, not here. This wrapped
|
|
31
|
+
# the send in its own instrument block, and ActiveSupport::Notifications
|
|
32
|
+
# publishes a block event once the block returns whatever it returned -
|
|
33
|
+
# so the event fired even when the send had failed and this method was
|
|
34
|
+
# about to answer nil, and fired a second time when the gateway
|
|
35
|
+
# instrumented the same send. Every subscriber counted a successful
|
|
36
|
+
# send twice and a failed one once.
|
|
37
|
+
result = begin
|
|
38
|
+
# Above the button cap there is no interactive surface that can
|
|
39
|
+
# carry media (see the renderer), so it rides in options[:media]
|
|
40
|
+
# and goes out as its own message ahead of the choice message.
|
|
41
|
+
send_message_payload(build_message_payload(options[:media], to)) if options[:media]
|
|
29
42
|
|
|
30
|
-
result = instrument(Events::MESSAGE_SENT, {
|
|
31
|
-
to: to,
|
|
32
|
-
message_type: type.to_s,
|
|
33
|
-
content_length: content.to_s.length,
|
|
34
|
-
platform: :whatsapp
|
|
35
|
-
}) do
|
|
36
43
|
message_data = build_message_payload(response, to)
|
|
37
44
|
send_message_payload(message_data)
|
|
38
45
|
end
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
module FlowChat
|
|
2
2
|
module Whatsapp
|
|
3
3
|
class Configuration
|
|
4
|
+
include FlowChat::NamedConfiguration
|
|
5
|
+
|
|
6
|
+
# "Whatsapp" is the constant, "WhatsApp" is the product.
|
|
7
|
+
def self.configuration_label
|
|
8
|
+
"WhatsApp"
|
|
9
|
+
end
|
|
10
|
+
|
|
4
11
|
attr_accessor :access_token, :phone_number_id, :verify_token, :app_id, :app_secret,
|
|
5
12
|
:webhook_verify_token, :business_account_id, :name, :skip_signature_validation
|
|
6
13
|
|
|
7
|
-
# Class-level storage for named configurations
|
|
8
|
-
@@configurations = {}
|
|
9
|
-
|
|
10
14
|
def initialize(name)
|
|
11
15
|
@name = name
|
|
12
16
|
@access_token = nil
|
|
@@ -60,56 +64,13 @@ module FlowChat
|
|
|
60
64
|
config
|
|
61
65
|
end
|
|
62
66
|
|
|
63
|
-
# Register a named configuration
|
|
64
|
-
def self.register(name, config)
|
|
65
|
-
FlowChat.logger.debug { "WhatsApp::Configuration: Registering configuration '#{name}'" }
|
|
66
|
-
@@configurations[name.to_sym] = config
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
# Get a named configuration
|
|
70
|
-
def self.get(name)
|
|
71
|
-
config = @@configurations[name.to_sym]
|
|
72
|
-
if config
|
|
73
|
-
FlowChat.logger.debug { "WhatsApp::Configuration: Retrieved configuration '#{name}'" }
|
|
74
|
-
config
|
|
75
|
-
else
|
|
76
|
-
FlowChat.logger.error { "WhatsApp::Configuration: Configuration '#{name}' not found" }
|
|
77
|
-
raise ArgumentError, "WhatsApp configuration '#{name}' not found"
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
# Check if a named configuration exists
|
|
82
|
-
def self.exists?(name)
|
|
83
|
-
exists = @@configurations.key?(name.to_sym)
|
|
84
|
-
FlowChat.logger.debug { "WhatsApp::Configuration: Configuration '#{name}' exists: #{exists}" }
|
|
85
|
-
exists
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
# Get all configuration names
|
|
89
|
-
def self.configuration_names
|
|
90
|
-
names = @@configurations.keys
|
|
91
|
-
FlowChat.logger.debug { "WhatsApp::Configuration: Available configurations: #{names}" }
|
|
92
|
-
names
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
# Clear all registered configurations (useful for testing)
|
|
96
|
-
def self.clear_all!
|
|
97
|
-
FlowChat.logger.debug { "WhatsApp::Configuration: Clearing all registered configurations" }
|
|
98
|
-
@@configurations.clear
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
# Register this configuration with a name
|
|
102
|
-
def register_as(name)
|
|
103
|
-
FlowChat.logger.debug { "WhatsApp::Configuration: Registering configuration as '#{name}'" }
|
|
104
|
-
@name = name.to_sym
|
|
105
|
-
self.class.register(@name, self)
|
|
106
|
-
self
|
|
107
|
-
end
|
|
108
|
-
|
|
109
67
|
def valid?
|
|
110
|
-
|
|
68
|
+
# Wrapped so a predicate answers true or false rather than nil, which
|
|
69
|
+
# the bare && chain returns for a missing first field. Intercom and
|
|
70
|
+
# Telegram already do this and pin it in their tests.
|
|
71
|
+
is_valid = !!(access_token && !access_token.to_s.empty? &&
|
|
111
72
|
phone_number_id && !phone_number_id.to_s.empty? &&
|
|
112
|
-
verify_token && !verify_token.to_s.empty?
|
|
73
|
+
verify_token && !verify_token.to_s.empty?)
|
|
113
74
|
|
|
114
75
|
FlowChat.logger.debug { "WhatsApp::Configuration: Configuration valid: #{is_valid}" }
|
|
115
76
|
is_valid
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
module FlowChat
|
|
2
|
+
module Whatsapp
|
|
3
|
+
# Its own file so Zeitwerk can resolve it by name. Declared inside
|
|
4
|
+
# gateway/cloud_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
|