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,234 @@
1
+ module FlowChat
2
+ module Whatsapp
3
+ module Middleware
4
+ # Maps what WhatsApp sends back to the choice key the flow branches on.
5
+ #
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.
9
+ #
10
+ # Flow:
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
17
+ #
18
+ # @example
19
+ # # Flow provides: {"create" => "Create Account"}
20
+ # # Titles built: "Create Account"
21
+ # # Transformed: {"Create Account" => "Create Account"}
22
+ # # Mapping: {"Create Account" => "create"}
23
+ #
24
+ # # With duplicates: {"yes" => "Accept", "no" => "Accept"}
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.
35
+ #
36
+ class ChoiceMapper
37
+ def initialize(app)
38
+ @app = app
39
+ FlowChat.logger.debug { "Whatsapp::ChoiceMapper: Initialized WhatsApp choice mapping middleware" }
40
+ end
41
+
42
+ def call(context)
43
+ @context = context
44
+ @session = context.session
45
+
46
+ session_id = context["session.id"]
47
+ FlowChat.logger.debug { "Whatsapp::ChoiceMapper: Processing request for session #{session_id}" }
48
+
49
+ if intercept?
50
+ FlowChat.logger.info { "Whatsapp::ChoiceMapper: Intercepting request for choice resolution - session #{session_id}" }
51
+ handle_choice_input
52
+ end
53
+
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
73
+
74
+ # Call the app (executor -> flow)
75
+ type, prompt, choices, media = @app.call(context)
76
+
77
+ # Transform choices if present (like USSD does)
78
+ if choices.present?
79
+ FlowChat.logger.debug { "Whatsapp::ChoiceMapper: Found choices, creating ID mapping" }
80
+ choices = create_id_mapping(choices)
81
+ end
82
+
83
+ [type, prompt, choices, media]
84
+ end
85
+
86
+ private
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
+
99
+ def intercept?
100
+ # Intercept if user input matches a generated id or a stored position
101
+ should_intercept = @context.input.present? && resolved_choice.present?
102
+
103
+ if should_intercept
104
+ FlowChat.logger.debug { "Whatsapp::ChoiceMapper: Intercepting - input: #{@context.input}, mapped to: #{resolved_choice}" }
105
+ end
106
+
107
+ should_intercept
108
+ end
109
+
110
+ def handle_choice_input
111
+ original_choice = resolved_choice
112
+
113
+ FlowChat.logger.info { "Whatsapp::ChoiceMapper: Resolving choice input #{@context.input} to #{original_choice}" }
114
+
115
+ # Replace the generated ID (or typed position) with the original choice key
116
+ @context.input = original_choice
117
+ end
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.
130
+ def create_id_mapping(choices)
131
+ cap = display_title_cap(choices.length)
132
+ return passthrough_mapping(choices) if cap.nil?
133
+
134
+ title_choices = {}
135
+ choice_mapping = {}
136
+
137
+ FlowChat::ChoiceTitles.build(choices, cap).each do |key, label, title, _truncated|
138
+ title_choices[title] = label
139
+ choice_mapping[title] = key
140
+ end
141
+
142
+ store_choice_mapping(choice_mapping)
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)
198
+ end
199
+
200
+ def store_choice_mapping(mapping)
201
+ @session.set("whatsapp.choice_mapping", mapping)
202
+ FlowChat.logger.debug { "Whatsapp::ChoiceMapper: Stored choice mapping: #{mapping}" }
203
+ end
204
+
205
+ def get_choice_mapping
206
+ @session.get("whatsapp.choice_mapping") || {}
207
+ end
208
+
209
+ def clear_choice_mapping
210
+ @session.delete("whatsapp.choice_mapping")
211
+ FlowChat.logger.debug { "Whatsapp::ChoiceMapper: Cleared choice mapping" }
212
+ end
213
+
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") || {}
221
+ end
222
+
223
+ def clear_position_mapping
224
+ @session.delete("whatsapp.position_mapping")
225
+ end
226
+
227
+ def clear_choice_state
228
+ clear_choice_mapping
229
+ clear_position_mapping
230
+ end
231
+ end
232
+ end
233
+ end
234
+ end
@@ -1,6 +1,24 @@
1
+ require "flow_chat/renderers/markdown_support"
2
+
1
3
  module FlowChat
2
4
  module Whatsapp
3
5
  class Renderer
6
+ include FlowChat::Renderers::MarkdownSupport
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
+
4
22
  attr_reader :message, :choices, :media
5
23
 
6
24
  def initialize(message, choices: nil, media: nil)
@@ -24,25 +42,66 @@ module FlowChat
24
42
  private
25
43
 
26
44
  def build_text_message
27
- [:text, message, {}]
45
+ [:text, to_whatsapp(message), {}]
46
+ end
47
+
48
+ def formatted_message
49
+ to_whatsapp(message)
50
+ end
51
+
52
+ def formatted_caption
53
+ message.present? ? to_whatsapp(message) : nil
28
54
  end
29
55
 
30
- 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)
31
61
  media_type = media[:type] || :image
32
- url = media[:url] || media[:path]
62
+ url = media[:url]
63
+ id = media[:id]
33
64
  filename = media[:filename]
34
65
 
35
66
  case media_type.to_sym
36
67
  when :image
37
- [:media_image, "", {url: url, caption: message}]
68
+ options = {}
69
+ options[:url] = url if url
70
+ options[:id] = id if id
71
+ options[:caption] = caption if caption
72
+ [:media_image, "", options]
38
73
  when :document
39
- [:media_document, "", {url: url, caption: message, filename: filename}]
74
+ options = {}
75
+ options[:url] = url if url
76
+ options[:id] = id if id
77
+ options[:caption] = caption if caption
78
+ options[:filename] = filename if filename
79
+ [:media_document, "", options]
40
80
  when :audio
41
- [:media_audio, "", {url: url, caption: message}]
81
+ options = {}
82
+ options[:url] = url if url
83
+ options[:id] = id if id
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.
87
+ [:media_audio, "", options]
42
88
  when :video
43
- [:media_video, "", {url: url, caption: message}]
89
+ options = {}
90
+ options[:url] = url if url
91
+ options[:id] = id if id
92
+ options[:caption] = caption if caption
93
+ [:media_video, "", options]
44
94
  when :sticker
45
- [:media_sticker, "", {url: url}] # Stickers don't support captions
95
+ options = {}
96
+ options[:url] = url if url
97
+ options[:id] = id if id
98
+ [:media_sticker, "", options] # Stickers don't support captions
99
+ when :template
100
+ [:template, "", {
101
+ template_name: media[:template_name],
102
+ components: media[:components] || [],
103
+ language: media[:language] || "en_US"
104
+ }]
46
105
  else
47
106
  raise ArgumentError, "Unsupported media type: #{media_type}"
48
107
  end
@@ -56,56 +115,110 @@ module FlowChat
56
115
  end
57
116
  end
58
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.
59
131
  def build_selection_message_with_media
60
- # 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
61
158
  if choices.is_a?(Array)
62
- choice_hash = choices.each_with_index.to_h { |choice, index| [index.to_s, choice] }
63
- build_buttons_message_with_media(choice_hash)
159
+ choices.each_with_index.to_h { |choice, index| [index.to_s, choice] }
64
160
  elsif choices.is_a?(Hash)
65
- build_buttons_message_with_media(choices)
161
+ choices
66
162
  else
67
163
  raise ArgumentError, "choices must be an Array or Hash"
68
164
  end
69
165
  end
70
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.
71
173
  def build_interactive_message(choice_hash)
72
- if choice_hash.length <= 3
73
- # Use buttons for 3 or fewer choices
74
- build_buttons_message(choice_hash)
75
- else
76
- # Use list for more than 3 choices
77
- 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)
78
178
  end
79
179
  end
80
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.
81
200
  def build_buttons_message(choices)
82
- buttons = choices.map do |key, value|
83
- {
84
- id: key.to_s,
85
- title: truncate_text(value.to_s, 20) # WhatsApp button titles have a 20 character limit
86
- }
201
+ buttons = FlowChat::ChoiceTitles.build(choices, BUTTON_TITLE_LENGTH).map do |key, _label, title, _truncated|
202
+ {id: key, title: title}
87
203
  end
88
204
 
89
- [:interactive_buttons, message, {buttons: buttons}]
205
+ [:interactive_buttons, formatted_message, {buttons: buttons}]
90
206
  end
91
207
 
92
208
  def build_buttons_message_with_media(choices)
93
- buttons = choices.map do |key, value|
94
- {
95
- id: key.to_s,
96
- title: truncate_text(value.to_s, 20) # WhatsApp button titles have a 20 character limit
97
- }
209
+ buttons = FlowChat::ChoiceTitles.build(choices, BUTTON_TITLE_LENGTH).map do |key, _label, title, _truncated|
210
+ {id: key, title: title}
98
211
  end
99
212
 
100
213
  # Build media header
101
214
  header = build_media_header
102
215
 
103
- [:interactive_buttons, message, {buttons: buttons, header: header}]
216
+ [:interactive_buttons, formatted_message, {buttons: buttons, header: header}]
104
217
  end
105
218
 
106
219
  def build_media_header
107
220
  media_type = media[:type] || :image
108
- url = media[:url] || media[:path]
221
+ url = media[:url]
109
222
  filename = media[:filename]
110
223
 
111
224
  case media_type.to_sym
@@ -136,50 +249,132 @@ module FlowChat
136
249
  end
137
250
  end
138
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.
139
262
  def build_list_message(choices)
140
- items = choices.map do |key, value|
141
- original_text = value.to_s
142
- truncated_title = truncate_text(original_text, 24)
143
-
144
- # If title was truncated, put full text in description (up to 72 chars)
145
- description = if original_text.length > 24
146
- truncate_text(original_text, 72)
147
- 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
148
265
 
149
266
  {
150
- id: key.to_s,
151
- title: truncated_title,
267
+ id: key,
268
+ title: title,
152
269
  description: description
153
270
  }.compact
154
271
  end
155
272
 
156
- # If 10 or fewer items, use single section
157
- sections = if items.length <= 10
158
- [
159
- {
160
- title: "Options",
161
- rows: items
162
- }
163
- ]
164
- else
165
- # Paginate into multiple sections (max 10 items per section)
166
- items.each_slice(10).with_index.map do |section_items, index|
167
- start_num = (index * 10) + 1
168
- end_num = start_num + section_items.length - 1
169
-
170
- {
171
- title: "#{start_num}-#{end_num}",
172
- rows: section_items
173
- }
174
- end
273
+ [:interactive_list, formatted_message, {sections: [{title: "Options", rows: items}]}]
274
+ end
275
+
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}", {}]
283
+ end
284
+
285
+ # Convert text to WhatsApp format
286
+ # Processes markdown through HTML, then converts HTML tags to WhatsApp syntax
287
+ def to_whatsapp(text)
288
+ return "" if text.nil?
289
+
290
+ # Pre-process: handle markdown features not supported by standard kramdown
291
+ processed = preprocess_markdown(text.to_s)
292
+
293
+ # Convert markdown to HTML
294
+ html = Kramdown::Document.new(processed, **kramdown_options).to_html.strip
295
+ html_to_whatsapp(html)
296
+ end
297
+
298
+ # Handle markdown features not natively supported by kramdown
299
+ def preprocess_markdown(text)
300
+ result = text.dup
301
+
302
+ # Convert fenced code blocks to indented code blocks (kramdown native format)
303
+ # ```lang\ncode\n``` → indented with 4 spaces
304
+ result.gsub!(/^```\w*\n(.*?)^```/m) do
305
+ code = $1
306
+ code.lines.map { |line| " #{line}" }.join
175
307
  end
176
308
 
177
- [:interactive_list, message, {sections: sections}]
309
+ # Convert ~~strikethrough~~ to HTML <del> tags (kramdown will pass through)
310
+ result.gsub!(/~~([^~]+)~~/, '<del>\1</del>')
311
+
312
+ result
178
313
  end
179
314
 
180
- def truncate_text(text, length)
181
- return text if text.length <= length
182
- text[0, length - 3] + "..."
315
+ # Convert HTML to WhatsApp formatting syntax
316
+ def html_to_whatsapp(html)
317
+ result = html.dup
318
+
319
+ # Convert code blocks first (before inline code)
320
+ # <pre><code>...</code></pre> → ```...```
321
+ result.gsub!(%r{<pre[^>]*><code[^>]*>(.*?)</code></pre>}m) { "```#{$1}```" }
322
+
323
+ # Convert inline code: <code>...</code> → `...`
324
+ result.gsub!(%r{<code[^>]*>(.*?)</code>}m) { "`#{$1}`" }
325
+
326
+ # Convert bold: <strong>...</strong> or <b>...</b> → *...*
327
+ result.gsub!(%r{<(?:strong|b)[^>]*>(.*?)</(?:strong|b)>}m) { "*#{$1}*" }
328
+
329
+ # Convert italic: <em>...</em> or <i>...</i> → _..._
330
+ result.gsub!(%r{<(?:em|i)[^>]*>(.*?)</(?:em|i)>}m) { "_#{$1}_" }
331
+
332
+ # Convert strikethrough: <s>...</s>, <del>...</del>, <strike>...</strike> → ~...~
333
+ result.gsub!(%r{<(?:s|del|strike)[^>]*>(.*?)</(?:s|del|strike)>}m) { "~#{$1}~" }
334
+
335
+ # Convert paragraphs to double newlines
336
+ result.gsub!(%r{<p[^>]*>(.*?)</p>}m) { "#{$1}\n\n" }
337
+
338
+ # Convert line breaks
339
+ result.gsub!(/<br\s*\/?>/, "\n")
340
+
341
+ # Convert links: <a href="url">text</a> → text (url)
342
+ # WhatsApp auto-links URLs, so we just show text and URL
343
+ result.gsub!(%r{<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)</a>}m) do
344
+ url, text = $1, $2
345
+ (text == url) ? url : "#{text} (#{url})"
346
+ end
347
+
348
+ # Convert blockquotes (WhatsApp doesn't have native support, use > prefix)
349
+ result.gsub!(%r{<blockquote[^>]*>(.*?)</blockquote>}m) do
350
+ $1.lines.map { |line| "> #{line.strip}" }.join("\n")
351
+ end
352
+
353
+ # Convert lists
354
+ result.gsub!(%r{<ul[^>]*>(.*?)</ul>}m) do
355
+ items = $1.scan(%r{<li[^>]*>(.*?)</li>}m).flatten
356
+ items.map { |item| "• #{item.strip}" }.join("\n")
357
+ end
358
+ result.gsub!(%r{<ol[^>]*>(.*?)</ol>}m) do
359
+ items = $1.scan(%r{<li[^>]*>(.*?)</li>}m).flatten
360
+ items.map.with_index(1) { |item, i| "#{i}. #{item.strip}" }.join("\n")
361
+ end
362
+
363
+ # Strip any remaining HTML tags
364
+ result.gsub!(/<[^>]+>/, "")
365
+
366
+ # Decode HTML entities
367
+ result.gsub!("&amp;", "&")
368
+ result.gsub!("&lt;", "<")
369
+ result.gsub!("&gt;", ">")
370
+ result.gsub!("&quot;", '"')
371
+ result.gsub!("&#39;", "'")
372
+ result.gsub!("&nbsp;", " ")
373
+
374
+ # Clean up excessive newlines
375
+ result.gsub!(/\n{3,}/, "\n\n")
376
+
377
+ result.strip
183
378
  end
184
379
  end
185
380
  end
data/lib/flow_chat.rb CHANGED
@@ -26,4 +26,4 @@ module FlowChat
26
26
  def self.metrics
27
27
  FlowChat::Instrumentation::Setup.metrics_collector
28
28
  end
29
- end
29
+ end