flow_chat 0.9.0 → 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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +43 -0
  3. data/CHANGELOG.md +26 -0
  4. data/README.md +166 -295
  5. data/Rakefile +12 -1
  6. data/SECURITY.md +1 -1
  7. data/docs/architecture.md +52 -479
  8. data/docs/async-background-processing.md +31 -265
  9. data/docs/configuration.md +106 -613
  10. data/docs/factory-pattern.md +27 -324
  11. data/docs/gateway-context-variables.md +140 -143
  12. data/docs/gateway-development.md +86 -650
  13. data/docs/getting-started.md +40 -379
  14. data/docs/instrumentation.md +88 -279
  15. data/docs/platforms/instagram.md +278 -0
  16. data/docs/platforms/messenger.md +205 -0
  17. data/docs/platforms/telegram.md +47 -951
  18. data/docs/platforms/ussd.md +38 -653
  19. data/docs/platforms/whatsapp.md +73 -1321
  20. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md +732 -0
  21. data/docs/superpowers/plans/2026-07-09-inbound-media-support.md.tasks.json +58 -0
  22. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md +4064 -0
  23. data/docs/superpowers/plans/2026-08-10-messenger-instagram.md.tasks.json +226 -0
  24. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md +972 -0
  25. data/docs/superpowers/plans/2026-08-16-unified-choice-resolution.md.tasks.json +88 -0
  26. data/docs/superpowers/specs/2026-07-09-inbound-media-support-design.md +195 -0
  27. data/docs/superpowers/specs/2026-08-10-messenger-instagram-design.md +391 -0
  28. data/docs/testing.md +26 -297
  29. data/examples/http_controller.rb +12 -10
  30. data/examples/intercom_configuration_example.rb +19 -24
  31. data/examples/intercom_controller.rb +8 -20
  32. data/examples/multi_tenant_whatsapp_controller.rb +61 -166
  33. data/examples/simulator_controller.rb +0 -1
  34. data/examples/ussd_controller.rb +86 -158
  35. data/examples/whatsapp_controller.rb +16 -15
  36. data/examples/whatsapp_media_examples.rb +27 -79
  37. data/lib/flow_chat/app.rb +129 -11
  38. data/lib/flow_chat/choice_titles.rb +95 -0
  39. data/lib/flow_chat/config.rb +124 -1
  40. data/lib/flow_chat/delivery_error.rb +9 -0
  41. data/lib/flow_chat/http/configuration_error.rb +9 -0
  42. data/lib/flow_chat/http/gateway/simple.rb +24 -4
  43. data/lib/flow_chat/http/middleware/choice_mapper.rb +94 -0
  44. data/lib/flow_chat/input.rb +86 -0
  45. data/lib/flow_chat/instagram/client.rb +32 -0
  46. data/lib/flow_chat/instagram/configuration.rb +147 -0
  47. data/lib/flow_chat/instagram/configuration_error.rb +7 -0
  48. data/lib/flow_chat/instagram/gateway/send_api.rb +63 -0
  49. data/lib/flow_chat/instagram/middleware/choice_mapper.rb +22 -0
  50. data/lib/flow_chat/instagram/renderer.rb +23 -0
  51. data/lib/flow_chat/instrumentation/metrics_collector.rb +6 -1
  52. data/lib/flow_chat/instrumentation.rb +160 -1
  53. data/lib/flow_chat/intercom/client.rb +34 -28
  54. data/lib/flow_chat/intercom/configuration.rb +2 -49
  55. data/lib/flow_chat/intercom/configuration_error.rb +9 -0
  56. data/lib/flow_chat/intercom/gateway/intercom_api.rb +81 -57
  57. data/lib/flow_chat/intercom/middleware/choice_mapper.rb +101 -0
  58. data/lib/flow_chat/intercom/renderer.rb +57 -5
  59. data/lib/flow_chat/media.rb +121 -0
  60. data/lib/flow_chat/messenger/client.rb +264 -0
  61. data/lib/flow_chat/messenger/configuration.rb +103 -0
  62. data/lib/flow_chat/messenger/configuration_error.rb +9 -0
  63. data/lib/flow_chat/messenger/gateway/send_api.rb +42 -0
  64. data/lib/flow_chat/messenger/middleware/choice_mapper.rb +185 -0
  65. data/lib/flow_chat/messenger/renderer.rb +150 -0
  66. data/lib/flow_chat/meta/challenge.rb +24 -0
  67. data/lib/flow_chat/meta/choice_ladder.rb +37 -0
  68. data/lib/flow_chat/meta/configuration_error.rb +7 -0
  69. data/lib/flow_chat/meta/gateway_identity.rb +38 -0
  70. data/lib/flow_chat/meta/messaging_gateway.rb +468 -0
  71. data/lib/flow_chat/meta/signature.rb +30 -0
  72. data/lib/flow_chat/meta/signature_validation.rb +66 -0
  73. data/lib/flow_chat/meta/webhook_verification.rb +43 -0
  74. data/lib/flow_chat/named_configuration.rb +65 -0
  75. data/lib/flow_chat/prompt.rb +13 -16
  76. data/lib/flow_chat/renderers/markdown_support.rb +109 -0
  77. data/lib/flow_chat/security.rb +76 -0
  78. data/lib/flow_chat/session/middleware.rb +11 -2
  79. data/lib/flow_chat/simulator/controller.rb +31 -15
  80. data/lib/flow_chat/simulator/views/simulator.html.erb +184 -20
  81. data/lib/flow_chat/telegram/client.rb +47 -4
  82. data/lib/flow_chat/telegram/configuration.rb +2 -42
  83. data/lib/flow_chat/telegram/configuration_error.rb +9 -0
  84. data/lib/flow_chat/telegram/gateway/bot_api.rb +46 -28
  85. data/lib/flow_chat/telegram/middleware/choice_mapper.rb +77 -16
  86. data/lib/flow_chat/telegram/renderer.rb +10 -2
  87. data/lib/flow_chat/text_truncator.rb +75 -0
  88. data/lib/flow_chat/ussd/middleware/choice_mapper.rb +10 -0
  89. data/lib/flow_chat/version.rb +1 -1
  90. data/lib/flow_chat/whatsapp/client.rb +14 -7
  91. data/lib/flow_chat/whatsapp/configuration.rb +12 -51
  92. data/lib/flow_chat/whatsapp/configuration_error.rb +9 -0
  93. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +254 -203
  94. data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +144 -57
  95. data/lib/flow_chat/whatsapp/renderer.rb +121 -60
  96. data/lib/flow_chat.rb +0 -10
  97. data/site/.nojekyll +0 -0
  98. data/site/.og-card.html +89 -0
  99. data/site/favicon.svg +6 -0
  100. data/site/index.html +209 -0
  101. data/site/og.png +0 -0
  102. metadata +51 -3
  103. data/lib/flow_chat/whatsapp/id_generator.rb +0 -124
@@ -11,7 +11,7 @@ class WhatsappController < ApplicationController
11
11
  config.use_session_store FlowChat::Session::CacheSessionStore
12
12
  end
13
13
 
14
- processor.run WelcomeFlow, :main_page
14
+ processor.run WhatsappWelcomeFlow, :main_page
15
15
  rescue => e
16
16
  Rails.logger.error "Error processing WhatsApp webhook: #{e.message}"
17
17
  head :internal_server_error
@@ -30,7 +30,7 @@ class CustomWhatsappController < ApplicationController
30
30
  config.use_session_store FlowChat::Session::CacheSessionStore
31
31
  end
32
32
 
33
- processor.run WelcomeFlow, :main_page
33
+ processor.run WhatsappWelcomeFlow, :main_page
34
34
  rescue => e
35
35
  Rails.logger.error "Error processing WhatsApp webhook: #{e.message}"
36
36
  head :internal_server_error
@@ -39,7 +39,8 @@ class CustomWhatsappController < ApplicationController
39
39
  private
40
40
 
41
41
  def build_whatsapp_config
42
- config = FlowChat::Whatsapp::Configuration.new
42
+ # Configuration#initialize requires a name; pass nil for an anonymous one.
43
+ config = FlowChat::Whatsapp::Configuration.new(nil)
43
44
 
44
45
  case Rails.env
45
46
  when "development", "test"
@@ -66,7 +67,7 @@ class CustomWhatsappController < ApplicationController
66
67
  end
67
68
 
68
69
  # Example flow for WhatsApp
69
- class WelcomeFlow < FlowChat::Flow
70
+ class WhatsappWelcomeFlow < FlowChat::Flow
70
71
  def main_page
71
72
  name = app.screen(:name) do |prompt|
72
73
  prompt.ask "Hello! What's your name?",
@@ -75,9 +76,9 @@ class WelcomeFlow < FlowChat::Flow
75
76
 
76
77
  choice = app.screen(:main_menu) do |prompt|
77
78
  prompt.select "Hi #{name}! How can I help?", {
78
- "info" => "📋 Get Information",
79
- "support" => "🆘 Contact Support",
80
- "feedback" => "💬 Give Feedback"
79
+ "info" => "Get Information",
80
+ "support" => "Contact Support",
81
+ "feedback" => "Give Feedback"
81
82
  }
82
83
  end
83
84
 
@@ -94,36 +95,36 @@ class WelcomeFlow < FlowChat::Flow
94
95
  private
95
96
 
96
97
  def show_info
97
- app.say "📍 Located at 123 Main Street\n🕒 Hours: Mon-Fri 9AM-6PM\n📞 Call: (555) 123-4567"
98
+ app.say "Located at 123 Main Street\nHours: Mon-Fri 9AM-6PM\nCall: (555) 123-4567"
98
99
  end
99
100
 
100
101
  def contact_support
101
102
  method = app.screen(:contact_method) do |prompt|
102
103
  prompt.select "How would you like to contact us?", {
103
- "call" => "📞 Call Us",
104
- "email" => "📧 Email Us"
104
+ "call" => "Call Us",
105
+ "email" => "Email Us"
105
106
  }
106
107
  end
107
108
 
108
109
  case method
109
110
  when "call"
110
- app.say "📞 Call us at (555) 123-4567"
111
+ app.say "Call us at (555) 123-4567"
111
112
  when "email"
112
- app.say "📧 Email us at support@example.com"
113
+ app.say "Email us at support@example.com"
113
114
  end
114
115
  end
115
116
 
116
117
  def collect_feedback
117
118
  rating = app.screen(:rating) do |prompt|
118
- prompt.select "Rate our service:", ["", "⭐⭐", "⭐⭐⭐", "⭐⭐⭐⭐", "⭐⭐⭐⭐⭐"]
119
+ prompt.select "Rate our service (1-5):", ["1", "2", "3", "4", "5"]
119
120
  end
120
121
 
121
122
  feedback = app.screen(:feedback_text) do |prompt|
122
123
  prompt.ask "Any additional comments?"
123
124
  end
124
125
 
125
- save_feedback(app.phone_number, rating, feedback)
126
- app.say "Thank you for your feedback! 🙏"
126
+ save_feedback(app.msisdn, rating, feedback)
127
+ app.say "Thank you for your feedback!"
127
128
  end
128
129
 
129
130
  def save_feedback(phone, rating, feedback)
@@ -1,66 +1,64 @@
1
1
  # WhatsApp Media Examples
2
- # This file demonstrates media usage with FlowChat's WhatsApp integration
2
+ # Demonstrates sending and receiving media with FlowChat's WhatsApp integration.
3
3
 
4
- # Basic media sending with WhatsApp Client
4
+ # Sending media directly with the WhatsApp client, out of band.
5
5
  config = FlowChat::Whatsapp::Configuration.from_credentials
6
6
  client = FlowChat::Whatsapp::Client.new(config)
7
7
 
8
- # Send different media types
9
8
  client.send_image("+1234567890", "https://example.com/image.jpg", "Caption")
10
9
  client.send_document("+1234567890", "https://example.com/doc.pdf", "Document title", "filename.pdf")
11
10
  client.send_audio("+1234567890", "https://example.com/audio.mp3")
12
11
  client.send_video("+1234567890", "https://example.com/video.mp4", "Video caption")
13
12
  client.send_sticker("+1234567890", "https://example.com/sticker.webp")
14
13
 
15
- # Using media in flows
14
+ # Sending and receiving media inside a flow.
16
15
  class MediaFlow < FlowChat::Flow
17
16
  def main_page
18
- # Handle incoming media
19
- if app.media
17
+ # app.media is always an Array<FlowChat::Media> (empty when the turn carried
18
+ # no attachment), so branch on whether it has any items.
19
+ if app.media.any?
20
20
  handle_user_media
21
21
  return
22
22
  end
23
23
 
24
- # Send media with prompts
24
+ # Attach media to a prompt.
25
25
  app.screen(:feedback) do |prompt|
26
26
  prompt.ask "What do you think?",
27
- media: {
28
- type: :image,
29
- url: "https://example.com/product.jpg"
30
- }
27
+ media: {type: :image, url: "https://example.com/product.jpg"}
31
28
  end
32
29
 
33
- # Send media responses
30
+ # Attach media to a terminal message.
34
31
  app.say "Thanks for your feedback!",
35
- media: {
36
- type: :video,
37
- url: "https://example.com/response.mp4"
38
- }
32
+ media: {type: :video, url: "https://example.com/response.mp4"}
39
33
  end
40
34
 
41
35
  private
42
36
 
43
37
  def handle_user_media
44
- media_type = app.media["type"]
38
+ # Each item is a FlowChat::Media; item.type is a normalized Symbol
39
+ # (:image, :video, :audio, :document, :sticker).
40
+ media = app.media.first
45
41
 
46
- case media_type
47
- when "image"
42
+ case media.type
43
+ when :image
48
44
  app.say "Thanks for the image! Processing..."
49
- when "document"
45
+ when :document
50
46
  app.say "Document received. Reviewing..."
51
- when "audio"
47
+ when :audio
52
48
  app.say "Got your voice message!"
53
- when "video"
49
+ when :video
54
50
  app.say "Video received. Analyzing..."
51
+ else
52
+ app.say "Attachment received."
55
53
  end
56
54
  end
57
55
  end
58
56
 
59
- # Media service for out-of-band messaging
57
+ # An out-of-band service for sending media outside a conversation, for example
58
+ # from a background job or another controller.
60
59
  class MediaService
61
60
  def initialize
62
- @config = FlowChat::Whatsapp::Configuration.from_credentials
63
- @client = FlowChat::Whatsapp::Client.new(@config)
61
+ @client = FlowChat::Whatsapp::Client.new(FlowChat::Whatsapp::Configuration.from_credentials)
64
62
  end
65
63
 
66
64
  def send_welcome_package(phone_number, user_name)
@@ -69,68 +67,18 @@ class MediaService
69
67
  end
70
68
 
71
69
  def send_order_confirmation(phone_number, order_id, invoice_url)
72
- @client.send_document(phone_number, invoice_url, "Order ##{order_id} confirmed!", "invoice.pdf")
73
- @client.send_buttons(phone_number, "Order confirmed! 🛍️", [
70
+ @client.send_document(phone_number, invoice_url, "Order ##{order_id} confirmed", "invoice.pdf")
71
+ @client.send_buttons(phone_number, "Order confirmed", [
74
72
  {id: "track", title: "Track Order"},
75
73
  {id: "support", title: "Contact Support"}
76
74
  ])
77
75
  end
78
-
79
- def process_user_media(media_id, media_type, user_phone)
80
- # Download and process media
81
- @client.get_media_url(media_id)
82
- media_content = @client.download_media(media_id)
83
-
84
- # Process based on type
85
- case media_type
86
- when "image"
87
- process_image(media_content, user_phone)
88
- when "document"
89
- process_document(media_content, user_phone)
90
- when "audio"
91
- process_audio(media_content, user_phone)
92
- end
93
- end
94
-
95
- private
96
-
97
- def process_image(content, phone)
98
- # Your image processing logic
99
- @client.send_text(phone, "Image processed successfully! ✅")
100
- end
101
-
102
- def process_document(content, phone)
103
- # Your document processing logic
104
- @client.send_text(phone, "Document processed! 📄")
105
- end
106
-
107
- def process_audio(content, phone)
108
- # Your audio processing logic
109
- @client.send_text(phone, "Audio processed! 🎵")
110
- end
111
76
  end
112
77
 
113
- # Controller example for media notifications
78
+ # Controller that sends media notifications on demand.
114
79
  class NotificationController < ApplicationController
115
80
  def send_media_notification
116
- service = MediaService.new
117
- service.send_welcome_package(params[:phone], params[:name])
118
- render json: {status: "sent"}
119
- end
120
-
121
- def send_order_confirmation
122
- service = MediaService.new
123
- service.send_order_confirmation(
124
- params[:phone],
125
- params[:order_id],
126
- generate_invoice_url(params[:order_id])
127
- )
81
+ MediaService.new.send_welcome_package(params[:phone], params[:name])
128
82
  render json: {status: "sent"}
129
83
  end
130
-
131
- private
132
-
133
- def generate_invoice_url(order_id)
134
- "https://storage.example.com/invoices/#{order_id}.pdf"
135
- end
136
84
  end
data/lib/flow_chat/app.rb CHANGED
@@ -4,7 +4,7 @@ module FlowChat
4
4
 
5
5
  def initialize(context)
6
6
  @context = context
7
- @input = context.input
7
+ @input = build_input
8
8
  @navigation_stack = []
9
9
  end
10
10
 
@@ -13,21 +13,57 @@ module FlowChat
13
13
  raise ArgumentError, "screen has already been presented" if navigation_stack.include?(key)
14
14
 
15
15
  navigation_stack << key
16
- return session.get(key) if session.get(key).present?
16
+ # A screen is answered once its key is stored — even when the stored value is
17
+ # blank (a caption-less attachment yields ""). Guard on presence-in-session
18
+ # (non-nil), not truthiness, so media-only (and false/blank) answers stick
19
+ # instead of re-asking every turn.
20
+ cached = session.get(key)
21
+ return cached unless cached.nil?
17
22
 
18
23
  user_input = prepare_user_input
19
24
  prompt = FlowChat::Prompt.new user_input
20
- @input = nil # input is being submitted to prompt so we clear it
25
+ # The turn has been handed to a screen; later screens in this run must not
26
+ # re-consume it. We mark it consumed rather than discarding it, so the
27
+ # read accessors (text/media/...) stay available for the rest of the run.
28
+ @input_consumed = true
21
29
 
22
30
  value = yield prompt
23
31
  session.set(key, value)
24
32
  value
25
33
  end
26
34
 
35
+ # Consume this turn's message, so no screen answers it later.
36
+ #
37
+ # A flow that reads the inbound outside the screen system says so with this.
38
+ # Routing on the opening message is the usual reason: the flow consumes the
39
+ # message itself, and FlowChat cannot see that read, so #screen would hand
40
+ # the same message to the first prompt the run reaches. That prompt is then
41
+ # answered without ever being asked, and its message never goes out.
42
+ #
43
+ # Records the first-message marker that #screen would have written, so the
44
+ # gate it guards does not fire a turn late and swallow the customer's reply
45
+ # as though it were the opener.
46
+ #
47
+ # Returns the turn, so the caller can read its text and any attachment. The
48
+ # context keeps both: what is given up is only this turn's right to answer a
49
+ # screen, not the message itself. Contrast #clear_turn!, which discards the
50
+ # turn outright because a restart has to rebuild the App without it.
51
+ def consume_turn!
52
+ taken = input
53
+ maybe_note_first_message!(taken)
54
+ @input_consumed = true
55
+ taken
56
+ end
57
+
27
58
  def go_back
28
59
  return false if navigation_stack.empty?
29
60
 
30
- @context.input = nil
61
+ # go_back raises RestartFlow, which the executor handles by rebuilding a
62
+ # fresh App from this same context. A per-instance @input_consumed flag
63
+ # would be lost on that rebuild, so clear the turn on the shared context
64
+ # too — otherwise the restarted screen re-consumes the back-trigger
65
+ # input/attachment as its answer instead of re-prompting.
66
+ clear_turn!
31
67
  current_screen = navigation_stack.last
32
68
  session.delete(current_screen)
33
69
 
@@ -63,16 +99,38 @@ module FlowChat
63
99
  context["request.timestamp"]
64
100
  end
65
101
 
102
+ # The sender's display name — distinct from a contact card they may share
103
+ # (that's #contact).
66
104
  def contact_name
67
- nil
105
+ context["request.user_name"]
68
106
  end
69
107
 
70
- def location
71
- nil
108
+ # Read accessors for the turn delegate to the Input value object, which is
109
+ # the single source of truth for this turn's text and attachments.
110
+ def text
111
+ input.text
72
112
  end
73
113
 
114
+ # Always an Array<FlowChat::Media> (empty when none) — a list even on
115
+ # single-media platforms, so callers iterate uniformly.
74
116
  def media
75
- nil
117
+ input.media
118
+ end
119
+
120
+ def location
121
+ input.location
122
+ end
123
+
124
+ def contact
125
+ input.contact
126
+ end
127
+
128
+ def attachment
129
+ input.attachment
130
+ end
131
+
132
+ def attachment_type
133
+ input.attachment_type
76
134
  end
77
135
 
78
136
  def session
@@ -81,13 +139,73 @@ module FlowChat
81
139
 
82
140
  protected
83
141
 
142
+ # Discard the current turn so a rebuilt App (after RestartFlow) sees an empty
143
+ # inbound message instead of re-consuming it. Clears both this instance and
144
+ # the shared context, since the restart builds a new instance from context.
145
+ def clear_turn!
146
+ @input_consumed = true
147
+ @input = FlowChat::Input.new
148
+ context.input = nil
149
+ context["request.media"] = nil
150
+ context["request.location"] = nil
151
+ context["request.contact"] = nil
152
+ end
153
+
154
+ # The turn as a FlowChat::Input value object. Its #present? accounts for
155
+ # attachments, so a caption-less photo still answers a screen even though its
156
+ # text is blank. Built once and kept for the whole run (see #screen).
157
+ def build_input
158
+ FlowChat::Input.new(
159
+ text: context.input,
160
+ media: wrap_media(context["request.media"]),
161
+ location: context["request.location"],
162
+ contact: context["request.contact"]
163
+ )
164
+ end
165
+
166
+ def wrap_media(raw)
167
+ return [] unless raw
168
+
169
+ items = raw.is_a?(Array) ? raw : [raw]
170
+ items.map { |data| FlowChat::Media.new(data, platform: platform, client: media_client) }
171
+ end
172
+
84
173
  def prepare_user_input
174
+ return nil if @input_consumed
175
+
85
176
  user_input = input
86
- if platform != :ussd && session.get(FlowChat::Input::START).nil?
87
- session.set(FlowChat::Input::START, user_input)
88
- user_input = nil
177
+ if maybe_note_first_message!(user_input)
178
+ # Swallow a text-only opener: the classic "wake the flow / show the first
179
+ # screen" behavior. An opener that carries an attachment is let through so
180
+ # the first screen can consume it rather than silently dropping the
181
+ # media/location/contact.
182
+ return nil unless user_input.attachment?
89
183
  end
90
184
  user_input
91
185
  end
186
+
187
+ # Records this session's opening message if it has not been recorded yet, and
188
+ # returns whether this turn was it. Whoever meets the turn first records it, a
189
+ # screen or a flow consuming it itself, so the marker does not depend on which
190
+ # of them got there. Stores the text, a serializable string, not the Input.
191
+ #
192
+ # USSD has no use for the marker: its gateway replays the whole session on
193
+ # every request, so there is no opener to distinguish.
194
+ def maybe_note_first_message!(turn)
195
+ return false if platform == :ussd
196
+ return false unless session.get(FlowChat::Input::START).nil?
197
+
198
+ session.set(FlowChat::Input::START, turn.to_s)
199
+ true
200
+ end
201
+
202
+ # Every gateway parks its client on the context under its own platform name,
203
+ # so the lookup is the platform rather than a case per platform. Media on
204
+ # Messenger and Instagram arrives as a direct CDN url and needs no client to
205
+ # fetch, but a nil client would still deny a Media object anything the client
206
+ # is the only route to.
207
+ def media_client
208
+ context["#{platform}.client"]
209
+ end
92
210
  end
93
211
  end
@@ -0,0 +1,95 @@
1
+ module FlowChat
2
+ # Decides, once per choice set rather than once per choice, whether every
3
+ # title in that set needs a 1-based position prefix ("1. ", "2. ", and so
4
+ # on), then returns the on-screen title FlowChat will render for each
5
+ # choice.
6
+ #
7
+ # The trigger is ambiguity, not truncation specifically: a screen is
8
+ # ambiguous when, having computed each choice's title at the rung's cap,
9
+ # either any title had to be truncated, or two choices land on the same
10
+ # title. Both mean the titles as displayed cannot identify a choice on
11
+ # their own:
12
+ #
13
+ # - Truncation can make two different labels ("Transfer to savings
14
+ # account" / "Transfer to salary account") land on the same displayed
15
+ # text ("Transfer to sa...").
16
+ # - Two choices can share a label outright with no truncation involved at
17
+ # all (two accounts both nicknamed "Savings", a menu with two literal
18
+ # "Accept" options) - the keys behind them differ, but the titles a user
19
+ # would type back are identical.
20
+ #
21
+ # Numbering is decided for the whole set, never per choice: prefixing only
22
+ # the affected title would produce "Yes" / "2. Transfer to savi...", a
23
+ # stray number with no "1." next to it to make sense of.
24
+ #
25
+ # The renderer and every choice mapper share this one decision, for the
26
+ # same reason FlowChat::TextTruncator is shared: two independent
27
+ # reimplementations could disagree about which rung is ambiguous, and a
28
+ # disagreement here means a title on screen that nothing resolves.
29
+ module ChoiceTitles
30
+ IDENTITY = ->(string) { string }
31
+
32
+ # @param choices [Hash] original choice key => label, in the order the
33
+ # caller numbers positions in - the renderer and the mapper must
34
+ # enumerate the same choices in the same order, or the titles and
35
+ # aliases they compute will not match
36
+ # @param cap [Integer] the rung's title length limit
37
+ # @return [Array<[String, String, String, Boolean]>] one
38
+ # [key, original_label, displayed_title, label_was_truncated] tuple per
39
+ # choice, in the same order as `choices`
40
+ # @param fold [Proc] the normalization the resolver applies to input
41
+ # before matching it. Two titles that fold to the same string cannot
42
+ # be told apart by that resolver, so the set is ambiguous and gets
43
+ # numbered. USSD is the one mapper that needs no fold: it resolves on
44
+ # position, which is injective by construction.
45
+ # @param measure [Symbol] :characters or :bytes, whichever unit the
46
+ # platform sizes the field in
47
+ def self.build(choices, cap, fold: IDENTITY, measure: :characters)
48
+ reason = ambiguity_reason(choices, cap, fold: fold, measure: measure)
49
+ prefixed = !reason.nil?
50
+
51
+ if prefixed
52
+ FlowChat.logger.debug { "#{name}: numbering choices, titles are ambiguous (#{reason})" }
53
+ end
54
+
55
+ choices.map.with_index(1) do |(key, label), position|
56
+ label = label.to_s
57
+ width = (measure == :bytes) ? label.bytesize : label.length
58
+
59
+ if prefixed
60
+ prefix = "#{position}. "
61
+ prefix_width = (measure == :bytes) ? prefix.bytesize : prefix.length
62
+ title = FlowChat::TextTruncator.number(label, position, cap, measure: measure)
63
+ truncated = width > (cap - prefix_width)
64
+ else
65
+ title = FlowChat::TextTruncator.truncate(label, cap, measure: measure)
66
+ truncated = width > cap
67
+ end
68
+
69
+ [key.to_s, label, title, truncated]
70
+ end
71
+ end
72
+
73
+ # @return [Boolean] whether this choice set is ambiguous at this cap
74
+ def self.ambiguous?(choices, cap, fold: IDENTITY, measure: :characters)
75
+ !ambiguity_reason(choices, cap, fold: fold, measure: measure).nil?
76
+ end
77
+
78
+ # @return [String, nil] a description of why the set is ambiguous, for
79
+ # logging, or nil when it is not
80
+ def self.ambiguity_reason(choices, cap, fold: IDENTITY, measure: :characters)
81
+ labels = choices.map { |_, label| label.to_s }
82
+ titles = labels.map { |label| FlowChat::TextTruncator.truncate(label, cap, measure: measure) }
83
+
84
+ truncated_labels = labels.zip(titles).select { |label, title| title != label }.map(&:first)
85
+ duplicate_titles = titles.map { |title| fold.call(title) }.tally.select { |_, count| count > 1 }.keys
86
+
87
+ return nil if truncated_labels.empty? && duplicate_titles.empty?
88
+
89
+ parts = []
90
+ parts << "truncated: #{truncated_labels.inspect}" unless truncated_labels.empty?
91
+ parts << "duplicate titles: #{duplicate_titles.inspect}" unless duplicate_titles.empty?
92
+ parts.join(", ")
93
+ end
94
+ end
95
+ end