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.
Files changed (147) hide show
  1. checksums.yaml +4 -4
  2. data/.cliff.toml +74 -0
  3. data/.github/workflows/ci.yml +2 -3
  4. data/.github/workflows/pages.yml +43 -0
  5. data/.github/workflows/release.yml +56 -0
  6. data/.standard.yml +4 -0
  7. data/CHANGELOG.md +48 -0
  8. data/CLAUDE.md +327 -0
  9. data/CONTRIBUTING.md +134 -0
  10. data/Gemfile +1 -0
  11. data/README.md +189 -133
  12. data/Rakefile +17 -2
  13. data/SECURITY.md +42 -349
  14. data/docs/architecture.md +83 -0
  15. data/docs/async-background-processing.md +64 -0
  16. data/docs/configuration.md +110 -287
  17. data/docs/factory-pattern.md +58 -0
  18. data/docs/gateway-context-variables.md +168 -0
  19. data/docs/gateway-development.md +159 -0
  20. data/docs/getting-started.md +90 -0
  21. data/docs/instrumentation.md +95 -175
  22. data/docs/platforms/instagram.md +278 -0
  23. data/docs/platforms/messenger.md +205 -0
  24. data/docs/platforms/telegram.md +109 -0
  25. data/docs/platforms/ussd.md +78 -0
  26. data/docs/platforms/whatsapp.md +147 -0
  27. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md +732 -0
  28. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json +58 -0
  29. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md +4064 -0
  30. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json +226 -0
  31. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md +972 -0
  32. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json +88 -0
  33. data/docs/superpowers/specs/2026-07-09-inbound-media-support-design.md +195 -0
  34. data/docs/superpowers/specs/2026-08-10-messenger-instagram-design.md +391 -0
  35. data/docs/testing.md +33 -426
  36. data/examples/custom_session_id_example.rb +119 -0
  37. data/examples/http_controller.rb +22 -20
  38. data/examples/intercom_configuration_example.rb +113 -0
  39. data/examples/intercom_controller.rb +182 -0
  40. data/examples/multi_tenant_whatsapp_controller.rb +63 -168
  41. data/examples/simulator_controller.rb +0 -1
  42. data/examples/ussd_controller.rb +88 -160
  43. data/examples/whatsapp_controller.rb +18 -17
  44. data/examples/whatsapp_media_examples.rb +27 -79
  45. data/flow_chat.gemspec +4 -0
  46. data/lib/flow_chat/app.rb +211 -0
  47. data/lib/flow_chat/async_job.rb +176 -0
  48. data/lib/flow_chat/choice_titles.rb +95 -0
  49. data/lib/flow_chat/config.rb +126 -23
  50. data/lib/flow_chat/delivery_error.rb +9 -0
  51. data/lib/flow_chat/{base_executor.rb → executor.rb} +6 -11
  52. data/lib/flow_chat/factory.rb +94 -0
  53. data/lib/flow_chat/gateway_async_support.rb +106 -0
  54. data/lib/flow_chat/generic_async_job.rb +30 -0
  55. data/lib/flow_chat/http/configuration_error.rb +9 -0
  56. data/lib/flow_chat/http/gateway/simple.rb +104 -36
  57. data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
  58. data/lib/flow_chat/http/renderer.rb +3 -3
  59. data/lib/flow_chat/input.rb +86 -0
  60. data/lib/flow_chat/instagram/client.rb +32 -0
  61. data/lib/flow_chat/instagram/configuration.rb +147 -0
  62. data/lib/flow_chat/instagram/configuration_error.rb +7 -0
  63. data/lib/flow_chat/instagram/gateway/send_api.rb +63 -0
  64. data/lib/flow_chat/instagram/middleware/choice_mapper.rb +22 -0
  65. data/lib/flow_chat/instagram/renderer.rb +23 -0
  66. data/lib/flow_chat/instrumentation/metrics_collector.rb +6 -1
  67. data/lib/flow_chat/instrumentation/setup.rb +1 -1
  68. data/lib/flow_chat/instrumentation.rb +182 -0
  69. data/lib/flow_chat/intercom/client.rb +161 -0
  70. data/lib/flow_chat/intercom/configuration.rb +102 -0
  71. data/lib/flow_chat/intercom/configuration_error.rb +9 -0
  72. data/lib/flow_chat/intercom/gateway/intercom_api.rb +420 -0
  73. data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
  74. data/lib/flow_chat/intercom/renderer.rb +123 -0
  75. data/lib/flow_chat/media.rb +121 -0
  76. data/lib/flow_chat/messenger/client.rb +264 -0
  77. data/lib/flow_chat/messenger/configuration.rb +103 -0
  78. data/lib/flow_chat/messenger/configuration_error.rb +9 -0
  79. data/lib/flow_chat/messenger/gateway/send_api.rb +42 -0
  80. data/lib/flow_chat/messenger/middleware/choice_mapper.rb +185 -0
  81. data/lib/flow_chat/messenger/renderer.rb +150 -0
  82. data/lib/flow_chat/meta/challenge.rb +24 -0
  83. data/lib/flow_chat/meta/choice_ladder.rb +37 -0
  84. data/lib/flow_chat/meta/configuration_error.rb +7 -0
  85. data/lib/flow_chat/meta/gateway_identity.rb +38 -0
  86. data/lib/flow_chat/meta/messaging_gateway.rb +468 -0
  87. data/lib/flow_chat/meta/signature.rb +30 -0
  88. data/lib/flow_chat/meta/signature_validation.rb +66 -0
  89. data/lib/flow_chat/meta/webhook_verification.rb +43 -0
  90. data/lib/flow_chat/named_configuration.rb +65 -0
  91. data/lib/flow_chat/phone_number_util.rb +37 -35
  92. data/lib/flow_chat/processor.rb +188 -0
  93. data/lib/flow_chat/prompt.rb +13 -16
  94. data/lib/flow_chat/renderers/markdown_support.rb +167 -0
  95. data/lib/flow_chat/security.rb +76 -0
  96. data/lib/flow_chat/session/middleware.rb +36 -11
  97. data/lib/flow_chat/simulator/controller.rb +31 -15
  98. data/lib/flow_chat/simulator/views/simulator.html.erb +184 -20
  99. data/lib/flow_chat/telegram/client.rb +283 -0
  100. data/lib/flow_chat/telegram/configuration.rb +78 -0
  101. data/lib/flow_chat/telegram/configuration_error.rb +9 -0
  102. data/lib/flow_chat/telegram/gateway/bot_api.rb +318 -0
  103. data/lib/flow_chat/telegram/middleware/choice_mapper.rb +96 -0
  104. data/lib/flow_chat/telegram/renderer.rb +133 -0
  105. data/lib/flow_chat/telegram.rb +7 -0
  106. data/lib/flow_chat/text_truncator.rb +75 -0
  107. data/lib/flow_chat/ussd/gateway/nalo.rb +24 -4
  108. data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
  109. data/lib/flow_chat/ussd/middleware/pagination.rb +9 -5
  110. data/lib/flow_chat/ussd/renderer.rb +1 -1
  111. data/lib/flow_chat/version.rb +1 -1
  112. data/lib/flow_chat/whatsapp/client.rb +158 -20
  113. data/lib/flow_chat/whatsapp/configuration.rb +13 -52
  114. data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
  115. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +335 -248
  116. data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +234 -0
  117. data/lib/flow_chat/whatsapp/renderer.rb +259 -64
  118. data/lib/flow_chat.rb +1 -1
  119. data/lib/tasks/release.rake +165 -0
  120. data/site/.nojekyll +0 -0
  121. data/site/.og-card.html +89 -0
  122. data/site/favicon.svg +6 -0
  123. data/site/index.html +209 -0
  124. data/site/og.png +0 -0
  125. metadata +132 -25
  126. data/docs/flows.md +0 -320
  127. data/docs/http-gateway-protocol.md +0 -432
  128. data/docs/images/simulator.png +0 -0
  129. data/docs/media.md +0 -153
  130. data/docs/sessions.md +0 -433
  131. data/docs/ussd-setup.md +0 -322
  132. data/docs/whatsapp-setup.md +0 -162
  133. data/examples/whatsapp_message_job.rb +0 -113
  134. data/lib/flow_chat/base_app.rb +0 -86
  135. data/lib/flow_chat/base_processor.rb +0 -146
  136. data/lib/flow_chat/http/app.rb +0 -6
  137. data/lib/flow_chat/http/middleware/executor.rb +0 -24
  138. data/lib/flow_chat/http/processor.rb +0 -33
  139. data/lib/flow_chat/session/rails_session_store.rb +0 -68
  140. data/lib/flow_chat/ussd/app.rb +0 -6
  141. data/lib/flow_chat/ussd/gateway/nsano.rb +0 -96
  142. data/lib/flow_chat/ussd/middleware/executor.rb +0 -24
  143. data/lib/flow_chat/ussd/processor.rb +0 -39
  144. data/lib/flow_chat/whatsapp/app.rb +0 -29
  145. data/lib/flow_chat/whatsapp/middleware/executor.rb +0 -24
  146. data/lib/flow_chat/whatsapp/processor.rb +0 -32
  147. 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,7 @@
1
+ # Telegram integration for FlowChat
2
+ # All files are autoloaded by Zeitwerk based on file path conventions
3
+
4
+ module FlowChat
5
+ module Telegram
6
+ end
7
+ 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