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.
Files changed (110) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +29 -0
  3. data/.github/workflows/pages.yml +43 -0
  4. data/.gitignore +4 -1
  5. data/CHANGELOG.md +49 -0
  6. data/README.md +166 -295
  7. data/Rakefile +12 -1
  8. data/SECURITY.md +1 -1
  9. data/docs/architecture.md +52 -479
  10. data/docs/async-background-processing.md +31 -265
  11. data/docs/configuration.md +106 -613
  12. data/docs/factory-pattern.md +27 -324
  13. data/docs/gateway-context-variables.md +140 -143
  14. data/docs/gateway-development.md +86 -650
  15. data/docs/getting-started.md +40 -379
  16. data/docs/instrumentation.md +88 -279
  17. data/docs/platforms/instagram.md +278 -0
  18. data/docs/platforms/messenger.md +205 -0
  19. data/docs/platforms/telegram.md +47 -951
  20. data/docs/platforms/ussd.md +38 -653
  21. data/docs/platforms/whatsapp.md +73 -1321
  22. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md +732 -0
  23. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json +58 -0
  24. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md +4064 -0
  25. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json +226 -0
  26. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md +972 -0
  27. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json +88 -0
  28. data/docs/superpowers/specs/2026-07-09-inbound-media-support-design.md +195 -0
  29. data/docs/superpowers/specs/2026-08-10-messenger-instagram-design.md +391 -0
  30. data/docs/testing.md +26 -297
  31. data/examples/http_controller.rb +12 -10
  32. data/examples/intercom_configuration_example.rb +19 -24
  33. data/examples/intercom_controller.rb +8 -20
  34. data/examples/multi_tenant_whatsapp_controller.rb +61 -166
  35. data/examples/simulator_controller.rb +0 -1
  36. data/examples/ussd_controller.rb +86 -158
  37. data/examples/whatsapp_controller.rb +16 -15
  38. data/examples/whatsapp_media_examples.rb +27 -79
  39. data/flow_chat.gemspec +22 -2
  40. data/gemfiles/rails_main.gemfile +18 -0
  41. data/lib/flow_chat/app.rb +129 -11
  42. data/lib/flow_chat/async_job.rb +10 -0
  43. data/lib/flow_chat/choice_titles.rb +95 -0
  44. data/lib/flow_chat/config.rb +124 -1
  45. data/lib/flow_chat/delivery_error.rb +9 -0
  46. data/lib/flow_chat/gateway_async_support.rb +19 -1
  47. data/lib/flow_chat/http/configuration_error.rb +9 -0
  48. data/lib/flow_chat/http/gateway/simple.rb +24 -4
  49. data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
  50. data/lib/flow_chat/input.rb +86 -0
  51. data/lib/flow_chat/instagram/client.rb +32 -0
  52. data/lib/flow_chat/instagram/configuration.rb +147 -0
  53. data/lib/flow_chat/instagram/configuration_error.rb +7 -0
  54. data/lib/flow_chat/instagram/gateway/send_api.rb +63 -0
  55. data/lib/flow_chat/instagram/middleware/choice_mapper.rb +22 -0
  56. data/lib/flow_chat/instagram/renderer.rb +23 -0
  57. data/lib/flow_chat/instrumentation/metrics_collector.rb +6 -1
  58. data/lib/flow_chat/instrumentation.rb +160 -1
  59. data/lib/flow_chat/intercom/client.rb +34 -28
  60. data/lib/flow_chat/intercom/configuration.rb +2 -49
  61. data/lib/flow_chat/intercom/configuration_error.rb +9 -0
  62. data/lib/flow_chat/intercom/gateway/intercom_api.rb +81 -57
  63. data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
  64. data/lib/flow_chat/intercom/renderer.rb +57 -5
  65. data/lib/flow_chat/media.rb +121 -0
  66. data/lib/flow_chat/messenger/client.rb +264 -0
  67. data/lib/flow_chat/messenger/configuration.rb +103 -0
  68. data/lib/flow_chat/messenger/configuration_error.rb +9 -0
  69. data/lib/flow_chat/messenger/gateway/send_api.rb +42 -0
  70. data/lib/flow_chat/messenger/middleware/choice_mapper.rb +185 -0
  71. data/lib/flow_chat/messenger/renderer.rb +150 -0
  72. data/lib/flow_chat/meta/challenge.rb +24 -0
  73. data/lib/flow_chat/meta/choice_ladder.rb +37 -0
  74. data/lib/flow_chat/meta/configuration_error.rb +7 -0
  75. data/lib/flow_chat/meta/gateway_identity.rb +38 -0
  76. data/lib/flow_chat/meta/messaging_gateway.rb +471 -0
  77. data/lib/flow_chat/meta/signature.rb +30 -0
  78. data/lib/flow_chat/meta/signature_validation.rb +66 -0
  79. data/lib/flow_chat/meta/webhook_verification.rb +43 -0
  80. data/lib/flow_chat/named_configuration.rb +65 -0
  81. data/lib/flow_chat/prompt.rb +13 -16
  82. data/lib/flow_chat/renderers/markdown_support.rb +109 -0
  83. data/lib/flow_chat/security.rb +76 -0
  84. data/lib/flow_chat/session/middleware.rb +11 -2
  85. data/lib/flow_chat/simulator/controller.rb +31 -15
  86. data/lib/flow_chat/simulator/views/simulator.html.erb +184 -20
  87. data/lib/flow_chat/telegram/client.rb +47 -4
  88. data/lib/flow_chat/telegram/configuration.rb +2 -42
  89. data/lib/flow_chat/telegram/configuration_error.rb +9 -0
  90. data/lib/flow_chat/telegram/gateway/bot_api.rb +46 -28
  91. data/lib/flow_chat/telegram/middleware/choice_mapper.rb +77 -16
  92. data/lib/flow_chat/telegram/renderer.rb +10 -2
  93. data/lib/flow_chat/text_truncator.rb +75 -0
  94. data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
  95. data/lib/flow_chat/version.rb +1 -1
  96. data/lib/flow_chat/whatsapp/client.rb +14 -7
  97. data/lib/flow_chat/whatsapp/configuration.rb +12 -51
  98. data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
  99. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +259 -203
  100. data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +144 -57
  101. data/lib/flow_chat/whatsapp/renderer.rb +121 -60
  102. data/lib/flow_chat.rb +0 -10
  103. data/lib/tasks/release.rake +11 -1
  104. data/site/.nojekyll +0 -0
  105. data/site/.og-card.html +89 -0
  106. data/site/favicon.svg +6 -0
  107. data/site/index.html +209 -0
  108. data/site/og.png +0 -0
  109. metadata +60 -11
  110. 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
- if result["ok"]
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
- {"ok" => false, "description" => error.message}
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
- attr_accessor :bot_token, :secret_token, :name, :skip_signature_validation
4
+ include FlowChat::NamedConfiguration
5
5
 
6
- @@configurations = {}
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.input.present?
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 = FlowChat::Input::LOCATION
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 = FlowChat::Input::MEDIA
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 = FlowChat::Input::MEDIA
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 = FlowChat::Input::MEDIA
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 = FlowChat::Input::MEDIA
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 = FlowChat::Input::MEDIA
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 = FlowChat::Input::MEDIA
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 = FlowChat::Input::CONTACT
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
- @client.send_message(context["request.id"], prompt, choices: choices, media: media)
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
- input = context.input
11
-
12
- # Check if input matches a stored choice key (for validation/logging)
13
- if context.session && input.present?
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
- # Store choices in session for validation
23
- if response
24
- _, _, choices, _ = response
25
- if choices.is_a?(Hash)
26
- context.session&.set("telegram_choices", choices)
27
- end
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
- response
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: truncate_text(value.to_s, 64),
78
- callback_data: key.to_s[0, 64]
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 = {}
@@ -1,3 +1,3 @@
1
1
  module FlowChat
2
- VERSION = "0.9.0"
2
+ VERSION = "0.10.1"
3
3
  end
@@ -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
- type, content, _options = response
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
- is_valid = access_token && !access_token.to_s.empty? &&
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