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
@@ -1,38 +1,37 @@
1
- require_relative "../id_generator"
2
-
3
1
  module FlowChat
4
2
  module Whatsapp
5
3
  module Middleware
6
- # Maps WhatsApp button/list IDs back to original choice keys
4
+ # Maps what WhatsApp sends back to the choice key the flow branches on.
7
5
  #
8
- # Similar to USSD::ChoiceMapper, but for WhatsApp interactive messages.
9
- # WhatsApp uses generated IDs (from IdGenerator) for buttons and list items,
10
- # and this middleware maps the user's response back to the original choice key.
6
+ # The value on the wire is the title the user was shown, so a tap and a
7
+ # user typing what they read arrive as the same string and resolve
8
+ # through the same map.
11
9
  #
12
10
  # Flow:
13
- # 1. Flow returns choices with original keys (e.g., {"create" => "Create Account"})
14
- # 2. Middleware generates WhatsApp-safe IDs from labels
15
- # 3. Middleware transforms choices to use generated IDs as keys
16
- # 4. Middleware stores mapping (generated_id original_key)
17
- # 5. Renderer receives transformed choices and renders them
18
- # 6. User selects a button/list item (WhatsApp sends the ID)
19
- # 7. This middleware intercepts and replaces ID with original key
20
- # 8. Flow sees the original choice key (not the generated ID)
11
+ # 1. Flow returns choices with original keys ({"create" => "Create Account"})
12
+ # 2. This middleware asks FlowChat::ChoiceTitles for each displayed title
13
+ # 3. It re-keys the choices by title and stores title => original key
14
+ # 4. Renderer receives the re-keyed choices and renders them
15
+ # 5. User taps a button, or types the title on it
16
+ # 6. This middleware resolves either back to the original key
21
17
  #
22
18
  # @example
23
19
  # # Flow provides: {"create" => "Create Account"}
24
- # # Middleware generates ID: "Create Account"
25
- # # Middleware transforms to: {"Create Account" => "Create Account"}
26
- # # Middleware stores: {"Create Account" => "create"}
27
- # # User clicks, WhatsApp sends: "Create Account"
28
- # # Middleware intercepts and maps back to: "create"
20
+ # # Titles built: "Create Account"
21
+ # # Transformed: {"Create Account" => "Create Account"}
22
+ # # Mapping: {"Create Account" => "create"}
29
23
  #
30
24
  # # With duplicates: {"yes" => "Accept", "no" => "Accept"}
31
- # # IDs generated: "Accept", "Accept 3a4"
32
- # # Transformed: {"Accept" => "Accept", "Accept 3a4" => "Accept"}
33
- # # Mapping: {"Accept" => "yes", "Accept 3a4" => "no"}
34
- # # User clicks second, WhatsApp sends: "Accept 3a4"
35
- # # Middleware maps back to: "no"
25
+ # # Titles built: "1. Accept", "2. Accept"
26
+ # # Mapping: {"1. Accept" => "yes", "2. Accept" => "no"}
27
+ #
28
+ # A title is truncated to the rung's cap (see
29
+ # FlowChat::Whatsapp::Renderer::BUTTON_TITLE_LENGTH /
30
+ # LIST_ROW_TITLE_LENGTH). When truncation, or a duplicate label, would
31
+ # leave two titles indistinguishable, FlowChat::ChoiceTitles numbers
32
+ # every title in the set instead - which is what keeps them distinct,
33
+ # and why nothing here has to check for collisions itself. See its docs
34
+ # for why that decision is made once for the whole set.
36
35
  #
37
36
  class ChoiceMapper
38
37
  def initialize(app)
@@ -52,8 +51,25 @@ module FlowChat
52
51
  handle_choice_input
53
52
  end
54
53
 
55
- # Clear choice mapping state for new flows
56
- clear_choice_state_if_needed
54
+ # The maps belong to exactly one screen: this turn's, if it had a
55
+ # resolvable answer, or one that already fell out of use otherwise.
56
+ # Either way nothing here is still owed to the next screen, so they
57
+ # are cleared unconditionally rather than asked whether they still
58
+ # look "live" - create_id_mapping immediately below repopulates
59
+ # them whenever the app actually returns choices.
60
+ #
61
+ # An earlier version asked should_clear_for_new_flow? that question
62
+ # after handle_choice_input had already rewritten @context.input to
63
+ # the *resolved* value, which can equal one of the map's own keys
64
+ # (an Array choice's key is its label, and the wire value is the
65
+ # title built from that label), so the check answered
66
+ # "still live" about a value that was never a fresh reply. That let
67
+ # the maps survive into a free-text screen and reinterpret a typed
68
+ # answer there as the previous menu's choice. This was fixed once
69
+ # here in Task 6 and once for Messenger in Task 13; both fixes had
70
+ # the same shape and the same blind spot, which is why the guard is
71
+ # gone rather than patched a third time.
72
+ clear_choice_state
57
73
 
58
74
  # Call the app (executor -> flow)
59
75
  type, prompt, choices, media = @app.call(context)
@@ -69,46 +85,116 @@ module FlowChat
69
85
 
70
86
  private
71
87
 
88
+ # Titles first, then positions. A tap sends the title as its payload
89
+ # and a user who types what they read sends the same string, so both
90
+ # arrive at the same entry - which is why there is no separate alias
91
+ # map any more. A position is the fallback, and only means anything
92
+ # when a number is genuinely on screen; it loses to a title because a
93
+ # choice labelled "1" would otherwise be unreachable.
94
+ def resolved_choice
95
+ input = @context.input.to_s
96
+ get_choice_mapping[input] || get_position_mapping[input]
97
+ end
98
+
72
99
  def intercept?
73
- # Intercept if we have choice mapping state and user input matches a generated ID
74
- choice_mapping = get_choice_mapping
75
- should_intercept = choice_mapping.present? &&
76
- @context.input.present? &&
77
- choice_mapping.key?(@context.input.to_s)
100
+ # Intercept if user input matches a generated id or a stored position
101
+ should_intercept = @context.input.present? && resolved_choice.present?
78
102
 
79
103
  if should_intercept
80
- FlowChat.logger.debug { "Whatsapp::ChoiceMapper: Intercepting - input: #{@context.input}, mapped to: #{choice_mapping[@context.input.to_s]}" }
104
+ FlowChat.logger.debug { "Whatsapp::ChoiceMapper: Intercepting - input: #{@context.input}, mapped to: #{resolved_choice}" }
81
105
  end
82
106
 
83
107
  should_intercept
84
108
  end
85
109
 
86
110
  def handle_choice_input
87
- choice_mapping = get_choice_mapping
88
- original_choice = choice_mapping[@context.input.to_s]
111
+ original_choice = resolved_choice
89
112
 
90
113
  FlowChat.logger.info { "Whatsapp::ChoiceMapper: Resolving choice input #{@context.input} to #{original_choice}" }
91
114
 
92
- # Replace the generated ID with the original choice key
115
+ # Replace the generated ID (or typed position) with the original choice key
93
116
  @context.input = original_choice
94
117
  end
95
118
 
119
+ # The value WhatsApp sends back IS the displayed title.
120
+ # FlowChat::ChoiceTitles guarantees the titles in a set are distinct -
121
+ # numbering the whole set when they would not be - so a title needs no
122
+ # separate id space to be unique in, and there is nothing left for a
123
+ # generated id to collide with.
124
+ #
125
+ # This replaces an id generated by normalizing the label, which was
126
+ # lossy: stripping "!" turned "Yes!" into the id "Yes", which was
127
+ # another choice's label verbatim, and the id map was consulted before
128
+ # the alias map. Titles are also bounded by the rung's cap (20 button,
129
+ # 24 list row), comfortably inside WhatsApp's id caps of 256 and 200.
96
130
  def create_id_mapping(choices)
97
- # Choices are always a hash after normalize_choices
98
- id_generator = IdGenerator.new
99
- id_choices = {}
131
+ cap = display_title_cap(choices.length)
132
+ return passthrough_mapping(choices) if cap.nil?
133
+
134
+ title_choices = {}
100
135
  choice_mapping = {}
101
136
 
102
- choices.each do |key, value|
103
- # Generate WhatsApp-safe ID from the label
104
- generated_id = id_generator.generate_id(value.to_s)
105
- id_choices[generated_id] = value
106
- choice_mapping[generated_id] = key.to_s
137
+ FlowChat::ChoiceTitles.build(choices, cap).each do |key, label, title, _truncated|
138
+ title_choices[title] = label
139
+ choice_mapping[title] = key
107
140
  end
108
141
 
109
142
  store_choice_mapping(choice_mapping)
110
- FlowChat.logger.debug { "Whatsapp::ChoiceMapper: Created mapping: #{choice_mapping}" }
111
- id_choices
143
+
144
+ if number_choices?(choices)
145
+ store_position_mapping(choices.keys.map.with_index(1) { |key, i| [i.to_s, key.to_s] }.to_h)
146
+ else
147
+ clear_position_mapping
148
+ end
149
+
150
+ title_choices
151
+ end
152
+
153
+ # Above the row cap the renderer numbers the body and prints each full
154
+ # label beside its number, so the label itself is what is on screen and
155
+ # stays resolvable - nothing is truncated, so there is no shortened
156
+ # form to key on instead.
157
+ #
158
+ # A label shared by two choices is dropped rather than resolved to the
159
+ # first of them. It identifies neither on a screen that shows both, and
160
+ # the number printed next to each is the reply that does.
161
+ def passthrough_mapping(choices)
162
+ labels = choices.map { |key, label| [label.to_s, key.to_s] }
163
+ repeated = labels.map(&:first).tally
164
+
165
+ store_choice_mapping(labels.reject { |label, _| repeated[label] > 1 }.to_h)
166
+ store_position_mapping(choices.keys.map.with_index(1) { |key, i| [i.to_s, key.to_s] }.to_h)
167
+ choices
168
+ end
169
+
170
+ # The title cap the renderer will use for these choices, or nil above
171
+ # the list cap, where the renderer falls back to a numbered body and
172
+ # shows the full label (no truncation, so no alias is needed).
173
+ #
174
+ # Goes through FlowChat::Meta::ChoiceLadder, the same helper
175
+ # FlowChat::Whatsapp::Renderer#build_interactive_message consults
176
+ # (via FlowChat::Config.whatsapp#ladder_limits), rather than
177
+ # re-deriving the rung from its own count comparisons: the mapper
178
+ # runs before the renderer and has no way to ask it which rung it
179
+ # chose, so both asking the same shared helper is what keeps them
180
+ # from disagreeing, the same reason Messenger's mapper does this too.
181
+ def display_title_cap(count)
182
+ case FlowChat::Meta::ChoiceLadder.rung_for(count, FlowChat::Config.whatsapp.ladder_limits)
183
+ when :quick_replies then FlowChat::Whatsapp::Renderer::BUTTON_TITLE_LENGTH
184
+ when :carousel then FlowChat::Whatsapp::Renderer::LIST_ROW_TITLE_LENGTH
185
+ end
186
+ end
187
+
188
+ # A position number is only worth resolving when one is genuinely on
189
+ # screen: above the row cap, where the renderer has no title left to
190
+ # show and numbers the body directly (display_title_cap is nil), or
191
+ # on a button/list rung whose titles FlowChat::ChoiceTitles decided
192
+ # were ambiguous and prefixed with a number. A short, unique set of
193
+ # titles (the common case) shows no number at all, so a typed digit
194
+ # there is free text, not a position.
195
+ def number_choices?(choices)
196
+ cap = display_title_cap(choices.length)
197
+ cap.nil? || FlowChat::ChoiceTitles.ambiguous?(choices, cap)
112
198
  end
113
199
 
114
200
  def store_choice_mapping(mapping)
@@ -125,21 +211,22 @@ module FlowChat
125
211
  FlowChat.logger.debug { "Whatsapp::ChoiceMapper: Cleared choice mapping" }
126
212
  end
127
213
 
128
- def clear_choice_state_if_needed
129
- # Clear choice mapping if this is a new flow (no input or fresh start)
130
- if @context.input.blank? || should_clear_for_new_flow?
131
- clear_choice_mapping
132
- end
214
+ def store_position_mapping(mapping)
215
+ @session.set("whatsapp.position_mapping", mapping)
216
+ FlowChat.logger.debug { "Whatsapp::ChoiceMapper: Stored position mapping: #{mapping}" }
217
+ end
218
+
219
+ def get_position_mapping
220
+ @session.get("whatsapp.position_mapping") || {}
133
221
  end
134
222
 
135
- def should_clear_for_new_flow?
136
- # Clear mapping if this input doesn't match any stored mapping
137
- # This indicates we're in a new flow step
138
- choice_mapping = get_choice_mapping
139
- return false if choice_mapping.empty?
223
+ def clear_position_mapping
224
+ @session.delete("whatsapp.position_mapping")
225
+ end
140
226
 
141
- # If input is present but doesn't match any mapping, we're in a new flow
142
- @context.input.present? && !choice_mapping.key?(@context.input.to_s)
227
+ def clear_choice_state
228
+ clear_choice_mapping
229
+ clear_position_mapping
143
230
  end
144
231
  end
145
232
  end
@@ -5,6 +5,20 @@ module FlowChat
5
5
  class Renderer
6
6
  include FlowChat::Renderers::MarkdownSupport
7
7
 
8
+ # WhatsApp button and list row title limits. The choice mapper reads
9
+ # these too, to alias the truncated title it knows this renderer will
10
+ # display for a given choice count.
11
+ BUTTON_TITLE_LENGTH = 20
12
+ LIST_ROW_TITLE_LENGTH = 24
13
+
14
+ # Meta: "Media asset caption text. Maximum 1024 characters." Documented
15
+ # for image, video, and document messages. Audio and sticker messages'
16
+ # own schemas list only id/link (audio also has voice) - no caption
17
+ # field at all - so they can never carry the numbered body this way,
18
+ # however short it is.
19
+ MAX_CAPTION_LENGTH = 1024
20
+ CAPTIONABLE_MEDIA_TYPES = [:image, :video, :document].freeze
21
+
8
22
  attr_reader :message, :choices, :media
9
23
 
10
24
  def initialize(message, choices: nil, media: nil)
@@ -39,7 +53,11 @@ module FlowChat
39
53
  message.present? ? to_whatsapp(message) : nil
40
54
  end
41
55
 
42
- def build_media_message
56
+ # @param caption [String, nil] defaults to the prompt text. Pass nil when
57
+ # this media is a companion sent ahead of a choice message: the prompt
58
+ # is about to appear in that message's body, and a caption here would
59
+ # just duplicate it.
60
+ def build_media_message(caption: formatted_caption)
43
61
  media_type = media[:type] || :image
44
62
  url = media[:url]
45
63
  id = media[:id]
@@ -50,26 +68,28 @@ module FlowChat
50
68
  options = {}
51
69
  options[:url] = url if url
52
70
  options[:id] = id if id
53
- options[:caption] = formatted_caption if formatted_caption
71
+ options[:caption] = caption if caption
54
72
  [:media_image, "", options]
55
73
  when :document
56
74
  options = {}
57
75
  options[:url] = url if url
58
76
  options[:id] = id if id
59
- options[:caption] = formatted_caption if formatted_caption
77
+ options[:caption] = caption if caption
60
78
  options[:filename] = filename if filename
61
79
  [:media_document, "", options]
62
80
  when :audio
63
81
  options = {}
64
82
  options[:url] = url if url
65
83
  options[:id] = id if id
66
- options[:caption] = formatted_caption if formatted_caption
84
+ # Meta's audio message schema carries id, link and voice, with no
85
+ # caption, so a caption here would claim a field the API does not
86
+ # accept. Same as stickers below.
67
87
  [:media_audio, "", options]
68
88
  when :video
69
89
  options = {}
70
90
  options[:url] = url if url
71
91
  options[:id] = id if id
72
- options[:caption] = formatted_caption if formatted_caption
92
+ options[:caption] = caption if caption
73
93
  [:media_video, "", options]
74
94
  when :sticker
75
95
  options = {}
@@ -95,45 +115,99 @@ module FlowChat
95
115
  end
96
116
  end
97
117
 
118
+ # Media is additive: it does not change which choice surface is used.
119
+ # 3 or fewer choices is the one case WhatsApp can carry both in a
120
+ # single message (buttons with a media header), so that stays as is.
121
+ # Above that, there is no interactive surface left that can carry
122
+ # media - Meta's interactive message reference documents header.type:
123
+ # text for list messages; image, video and document headers are only
124
+ # defined for button messages - so the media goes out as its own
125
+ # message and the list or numbered rendering follows unchanged.
126
+ #
127
+ # The one exception is the numbered rung above the list cap: there the
128
+ # choices are already nothing but text, and a captionable media
129
+ # message can carry that text itself, so a captioned single message
130
+ # replaces the media-then-text split whenever it fits.
98
131
  def build_selection_message_with_media
99
- # Convert array to hash with index-based keys if needed, same as build_selection_message
132
+ choice_hash = normalized_choices
133
+
134
+ return build_buttons_message_with_media(choice_hash) if buttons_rung?(choice_hash.length)
135
+
136
+ type, content, options = build_interactive_message(choice_hash)
137
+
138
+ if type == :text
139
+ merged = media_caption_message(content)
140
+ return merged if merged
141
+ end
142
+
143
+ [type, content, options.merge(media: build_media_message(caption: nil))]
144
+ end
145
+
146
+ # Returns the single-message [type, content, options] triple when the
147
+ # media type can carry a caption and the combined text fits under
148
+ # Meta's cap, or nil when it does not - the caller falls back to the
149
+ # existing media-then-text split, which loses nothing.
150
+ def media_caption_message(content)
151
+ return nil unless CAPTIONABLE_MEDIA_TYPES.include?((media[:type] || :image).to_sym)
152
+ return nil if content.length > MAX_CAPTION_LENGTH
153
+
154
+ build_media_message(caption: content)
155
+ end
156
+
157
+ def normalized_choices
100
158
  if choices.is_a?(Array)
101
- choice_hash = choices.each_with_index.to_h { |choice, index| [index.to_s, choice] }
102
- build_buttons_message_with_media(choice_hash)
159
+ choices.each_with_index.to_h { |choice, index| [index.to_s, choice] }
103
160
  elsif choices.is_a?(Hash)
104
- build_buttons_message_with_media(choices)
161
+ choices
105
162
  else
106
163
  raise ArgumentError, "choices must be an Array or Hash"
107
164
  end
108
165
  end
109
166
 
167
+ # Goes through FlowChat::Meta::ChoiceLadder, shared with Messenger and
168
+ # Instagram, rather than its own count comparisons - see
169
+ # FlowChat::Config::WhatsappConfig#ladder_limits for how WhatsApp's two
170
+ # flat thresholds are bridged to the shape that helper expects. The
171
+ # choice mapper asks the same question the same way, so the two cannot
172
+ # drift on which rung a given count lands on.
110
173
  def build_interactive_message(choice_hash)
111
- if choice_hash.length <= 3
112
- # Use buttons for 3 or fewer choices
113
- build_buttons_message(choice_hash)
114
- else
115
- # Use list for more than 3 choices
116
- build_list_message(choice_hash)
174
+ case FlowChat::Meta::ChoiceLadder.rung_for(choice_hash.length, limits.ladder_limits)
175
+ when :none, :quick_replies then build_buttons_message(choice_hash)
176
+ when :carousel then build_list_message(choice_hash)
177
+ when :numbered then build_numbered_message(choice_hash)
117
178
  end
118
179
  end
119
180
 
181
+ # :none (zero choices) counts as a buttons-rung count here, matching
182
+ # what the plain `count <= max_buttons` comparison this replaced did
183
+ # for zero: there is no real-world case with zero choices, but this
184
+ # keeps the two call sites in build_selection_message_with_media and
185
+ # build_interactive_message agreeing with each other regardless.
186
+ def buttons_rung?(count)
187
+ rung = FlowChat::Meta::ChoiceLadder.rung_for(count, limits.ladder_limits)
188
+ rung == :none || rung == :quick_replies
189
+ end
190
+
191
+ def limits
192
+ FlowChat::Config.whatsapp
193
+ end
194
+
195
+ # Whether titles are numbered, and the enumeration order positions come
196
+ # from, are both decided by FlowChat::ChoiceTitles over this same
197
+ # `choices` hash - the choice mapper goes through the same module over
198
+ # the same hash, so the two can never disagree on which titles are
199
+ # shown, which is what lets the mapper use a title as the wire value.
120
200
  def build_buttons_message(choices)
121
- buttons = choices.map do |key, value|
122
- {
123
- id: key.to_s,
124
- title: truncate_text(value.to_s, 20) # WhatsApp button titles have a 20 character limit
125
- }
201
+ buttons = FlowChat::ChoiceTitles.build(choices, BUTTON_TITLE_LENGTH).map do |key, _label, title, _truncated|
202
+ {id: key, title: title}
126
203
  end
127
204
 
128
205
  [:interactive_buttons, formatted_message, {buttons: buttons}]
129
206
  end
130
207
 
131
208
  def build_buttons_message_with_media(choices)
132
- buttons = choices.map do |key, value|
133
- {
134
- id: key.to_s,
135
- title: truncate_text(value.to_s, 20) # WhatsApp button titles have a 20 character limit
136
- }
209
+ buttons = FlowChat::ChoiceTitles.build(choices, BUTTON_TITLE_LENGTH).map do |key, _label, title, _truncated|
210
+ {id: key, title: title}
137
211
  end
138
212
 
139
213
  # Build media header
@@ -175,50 +249,37 @@ module FlowChat
175
249
  end
176
250
  end
177
251
 
252
+ # See the comment on build_buttons_message: numbering and position both
253
+ # come from FlowChat::ChoiceTitles over this same `choices` hash.
254
+ #
255
+ # The description (a longer, secondary line WhatsApp renders below the
256
+ # title, up to 72 chars) is not numbered: nothing resolves a typed
257
+ # description back to a choice, only the title and the position are
258
+ # aliased, so a prefix there would just be noise. It is populated
259
+ # whenever the title's own label portion didn't fit - whether that's
260
+ # because the label alone exceeds the cap, or because a position
261
+ # prefix ate into the room left for it.
178
262
  def build_list_message(choices)
179
- items = choices.map do |key, value|
180
- original_text = value.to_s
181
- truncated_title = truncate_text(original_text, 24)
182
-
183
- # If title was truncated, put full text in description (up to 72 chars)
184
- description = if original_text.length > 24
185
- truncate_text(original_text, 72)
186
- end
263
+ items = FlowChat::ChoiceTitles.build(choices, LIST_ROW_TITLE_LENGTH).map do |key, label, title, truncated|
264
+ description = FlowChat::TextTruncator.truncate(label, 72) if truncated
187
265
 
188
266
  {
189
- id: key.to_s,
190
- title: truncated_title,
267
+ id: key,
268
+ title: title,
191
269
  description: description
192
270
  }.compact
193
271
  end
194
272
 
195
- # If 10 or fewer items, use single section
196
- sections = if items.length <= 10
197
- [
198
- {
199
- title: "Options",
200
- rows: items
201
- }
202
- ]
203
- else
204
- # Paginate into multiple sections (max 10 items per section)
205
- items.each_slice(10).with_index.map do |section_items, index|
206
- start_num = (index * 10) + 1
207
- end_num = start_num + section_items.length - 1
208
-
209
- {
210
- title: "#{start_num}-#{end_num}",
211
- rows: section_items
212
- }
213
- end
214
- end
215
-
216
- [:interactive_list, formatted_message, {sections: sections}]
273
+ [:interactive_list, formatted_message, {sections: [{title: "Options", rows: items}]}]
217
274
  end
218
275
 
219
- def truncate_text(text, length)
220
- return text if text.length <= length
221
- text[0, length - 3] + "..."
276
+ # Above the row cap there is no interactive surface left, so the options go
277
+ # in the body and the user types a number. The choice mapper stores the
278
+ # positions for this rung so the digit resolves to the original key.
279
+ def build_numbered_message(choices)
280
+ numbered = choices.values.map.with_index(1) { |label, i| "#{i}. #{label}" }.join("\n")
281
+
282
+ [:text, "#{formatted_message}\n\n#{numbered}", {}]
222
283
  end
223
284
 
224
285
  # Convert text to WhatsApp format
data/lib/flow_chat.rb CHANGED
@@ -10,16 +10,6 @@ loader.enable_reloading if defined?(Rails.env) && Rails.env.development?
10
10
  loader.setup
11
11
 
12
12
  module FlowChat
13
- # Special input markers for non-text message types
14
- module Input
15
- LOCATION = "$location$"
16
- MEDIA = "$media$"
17
- CONTACT = "$contact$"
18
-
19
- # Session markers
20
- START = "$start$"
21
- end
22
-
23
13
  def self.root
24
14
  Pathname.new __dir__
25
15
  end
data/site/.nojekyll ADDED
File without changes
@@ -0,0 +1,89 @@
1
+ <!doctype html>
2
+ <!--
3
+ Source for the social preview image (og.png), rendered at 1200x630.
4
+ Regenerate after editing this file:
5
+
6
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
7
+ --headless --disable-gpu --hide-scrollbars --force-device-scale-factor=1 \
8
+ --window-size=1200,630 \
9
+ --screenshot="site/og.png" "file://$PWD/site/.og-card.html"
10
+
11
+ Any headless Chromium works. Keep the window size at 1200x630 (the 1.91:1
12
+ ratio OpenGraph/Twitter scrapers expect). This file is a dotfile so it stays
13
+ out of normal listings; it is only ever opened locally to produce og.png.
14
+ -->
15
+ <html lang="en">
16
+ <head>
17
+ <meta charset="utf-8">
18
+ <style>
19
+ * { box-sizing: border-box; margin: 0; padding: 0; }
20
+ html, body { width: 1200px; height: 630px; }
21
+ :root {
22
+ --bg: #101317; --fg: #d9dde3; --soft: #8b93a0; --accent: #8b83f0;
23
+ --rule: #262b32; --code-bg: #191d23;
24
+ --mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
25
+ --sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
26
+ }
27
+ body {
28
+ background: var(--bg);
29
+ color: var(--fg);
30
+ font-family: var(--sans);
31
+ -webkit-font-smoothing: antialiased;
32
+ position: relative;
33
+ overflow: hidden;
34
+ }
35
+ .frame {
36
+ position: absolute; inset: 64px 72px;
37
+ display: flex; flex-direction: column; justify-content: space-between;
38
+ }
39
+ .top { display: flex; align-items: center; gap: 28px; }
40
+ .glyph { width: 108px; height: 108px; flex: none; display: block; }
41
+ .wordmark {
42
+ font-family: var(--mono);
43
+ font-size: 68px;
44
+ font-weight: 600;
45
+ letter-spacing: -0.01em;
46
+ line-height: 1;
47
+ }
48
+ .tagline {
49
+ font-size: 40px;
50
+ line-height: 1.32;
51
+ max-width: 1010px;
52
+ font-weight: 400;
53
+ }
54
+ .tagline b { font-weight: 600; }
55
+ .foot {
56
+ display: flex; align-items: center; justify-content: space-between;
57
+ font-family: var(--mono);
58
+ font-size: 24px;
59
+ color: var(--soft);
60
+ border-top: 1px solid var(--rule);
61
+ padding-top: 22px;
62
+ }
63
+ .foot .r { color: var(--accent); }
64
+ </style>
65
+ </head>
66
+ <body>
67
+ <div class="frame">
68
+ <div class="top">
69
+ <svg class="glyph" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
70
+ <rect width="32" height="32" rx="7" fill="#4f46c9"/>
71
+ <path d="M16 7h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-1v3l-3-3h-4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2z" fill="none" stroke="#fff" stroke-width="1.7" opacity="0.6"/>
72
+ <path d="M8 13h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4l-3 3v-3H8a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2z" fill="#fff"/>
73
+ </svg>
74
+ <div class="wordmark">FlowChat</div>
75
+ </div>
76
+
77
+ <div class="tagline">
78
+ Write a conversation as an ordinary Ruby method. FlowChat replays it across
79
+ stateless webhooks, on <b>USSD</b>, <b>WhatsApp</b>, <b>Telegram</b>, and
80
+ <b>HTTP</b>.
81
+ </div>
82
+
83
+ <div class="foot">
84
+ <span>Conversational interfaces for Rails</span>
85
+ <span class="r">Radioactive Labs</span>
86
+ </div>
87
+ </div>
88
+ </body>
89
+ </html>
data/site/favicon.svg ADDED
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
2
+ <rect width="32" height="32" rx="7" fill="#4f46c9"/>
3
+ <!-- two overlapping speech bubbles: a dialogue between two sides -->
4
+ <path d="M16 7h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-1v3l-3-3h-4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2z" fill="none" stroke="#fff" stroke-width="1.7" opacity="0.6"/>
5
+ <path d="M8 13h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4l-3 3v-3H8a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2z" fill="#fff"/>
6
+ </svg>