flow_chat 0.8.2 → 0.9.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 (91) 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/release.yml +56 -0
  5. data/.standard.yml +4 -0
  6. data/CHANGELOG.md +22 -0
  7. data/CLAUDE.md +327 -0
  8. data/CONTRIBUTING.md +134 -0
  9. data/Gemfile +1 -0
  10. data/README.md +290 -105
  11. data/Rakefile +5 -1
  12. data/SECURITY.md +42 -349
  13. data/docs/architecture.md +510 -0
  14. data/docs/async-background-processing.md +298 -0
  15. data/docs/configuration.md +556 -226
  16. data/docs/factory-pattern.md +355 -0
  17. data/docs/gateway-context-variables.md +171 -0
  18. data/docs/gateway-development.md +723 -0
  19. data/docs/getting-started.md +429 -0
  20. data/docs/instrumentation.md +264 -153
  21. data/docs/platforms/telegram.md +1013 -0
  22. data/docs/platforms/ussd.md +693 -0
  23. data/docs/platforms/whatsapp.md +1395 -0
  24. data/docs/testing.md +243 -365
  25. data/examples/custom_session_id_example.rb +119 -0
  26. data/examples/http_controller.rb +11 -11
  27. data/examples/intercom_configuration_example.rb +118 -0
  28. data/examples/intercom_controller.rb +194 -0
  29. data/examples/multi_tenant_whatsapp_controller.rb +4 -4
  30. data/examples/ussd_controller.rb +9 -9
  31. data/examples/whatsapp_controller.rb +2 -2
  32. data/flow_chat.gemspec +4 -0
  33. data/lib/flow_chat/{base_app.rb → app.rb} +16 -9
  34. data/lib/flow_chat/async_job.rb +176 -0
  35. data/lib/flow_chat/config.rb +10 -30
  36. data/lib/flow_chat/{base_executor.rb → executor.rb} +6 -11
  37. data/lib/flow_chat/factory.rb +94 -0
  38. data/lib/flow_chat/gateway_async_support.rb +106 -0
  39. data/lib/flow_chat/generic_async_job.rb +30 -0
  40. data/lib/flow_chat/http/gateway/simple.rb +81 -33
  41. data/lib/flow_chat/http/renderer.rb +3 -3
  42. data/lib/flow_chat/instrumentation/setup.rb +1 -1
  43. data/lib/flow_chat/instrumentation.rb +23 -0
  44. data/lib/flow_chat/intercom/client.rb +155 -0
  45. data/lib/flow_chat/intercom/configuration.rb +149 -0
  46. data/lib/flow_chat/intercom/gateway/intercom_api.rb +396 -0
  47. data/lib/flow_chat/intercom/renderer.rb +71 -0
  48. data/lib/flow_chat/phone_number_util.rb +37 -35
  49. data/lib/flow_chat/processor.rb +188 -0
  50. data/lib/flow_chat/renderers/markdown_support.rb +58 -0
  51. data/lib/flow_chat/session/middleware.rb +25 -9
  52. data/lib/flow_chat/telegram/client.rb +240 -0
  53. data/lib/flow_chat/telegram/configuration.rb +118 -0
  54. data/lib/flow_chat/telegram/gateway/bot_api.rb +300 -0
  55. data/lib/flow_chat/telegram/middleware/choice_mapper.rb +35 -0
  56. data/lib/flow_chat/telegram/renderer.rb +125 -0
  57. data/lib/flow_chat/telegram.rb +7 -0
  58. data/lib/flow_chat/ussd/gateway/nalo.rb +24 -4
  59. data/lib/flow_chat/ussd/middleware/pagination.rb +9 -5
  60. data/lib/flow_chat/ussd/renderer.rb +1 -1
  61. data/lib/flow_chat/version.rb +1 -1
  62. data/lib/flow_chat/whatsapp/client.rb +144 -13
  63. data/lib/flow_chat/whatsapp/configuration.rb +1 -1
  64. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +132 -96
  65. data/lib/flow_chat/whatsapp/id_generator.rb +124 -0
  66. data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +147 -0
  67. data/lib/flow_chat/whatsapp/renderer.rb +145 -11
  68. data/lib/flow_chat.rb +11 -1
  69. data/lib/tasks/release.rake +165 -0
  70. metadata +84 -25
  71. data/docs/flows.md +0 -320
  72. data/docs/http-gateway-protocol.md +0 -432
  73. data/docs/images/simulator.png +0 -0
  74. data/docs/media.md +0 -153
  75. data/docs/sessions.md +0 -433
  76. data/docs/ussd-setup.md +0 -322
  77. data/docs/whatsapp-setup.md +0 -162
  78. data/examples/whatsapp_message_job.rb +0 -113
  79. data/lib/flow_chat/base_processor.rb +0 -146
  80. data/lib/flow_chat/http/app.rb +0 -6
  81. data/lib/flow_chat/http/middleware/executor.rb +0 -24
  82. data/lib/flow_chat/http/processor.rb +0 -33
  83. data/lib/flow_chat/session/rails_session_store.rb +0 -68
  84. data/lib/flow_chat/ussd/app.rb +0 -6
  85. data/lib/flow_chat/ussd/gateway/nsano.rb +0 -96
  86. data/lib/flow_chat/ussd/middleware/executor.rb +0 -24
  87. data/lib/flow_chat/ussd/processor.rb +0 -39
  88. data/lib/flow_chat/whatsapp/app.rb +0 -29
  89. data/lib/flow_chat/whatsapp/middleware/executor.rb +0 -24
  90. data/lib/flow_chat/whatsapp/processor.rb +0 -32
  91. data/lib/flow_chat/whatsapp/send_job_support.rb +0 -79
@@ -0,0 +1,124 @@
1
+ require "digest"
2
+
3
+ module FlowChat
4
+ module Whatsapp
5
+ # Generates WhatsApp-safe IDs from choice labels
6
+ #
7
+ # WhatsApp Cloud API requires button and list item IDs to be:
8
+ # - Maximum 256 characters
9
+ # - Unique within the message
10
+ # - Non-empty strings
11
+ #
12
+ # This generator:
13
+ # - Sanitizes labels to alphanumeric + underscore/hyphen
14
+ # - Truncates to fit within 256-char limit
15
+ # - Appends hash suffix for duplicate labels (similar to Rails index naming)
16
+ # - Ensures readability while maintaining uniqueness
17
+ #
18
+ # @example Basic usage
19
+ # generator = IdGenerator.new
20
+ # id = generator.generate_id("Create Account") # => "create_account"
21
+ #
22
+ # @example Handling duplicates
23
+ # generator = IdGenerator.new
24
+ # id1 = generator.generate_id("Accept") # => "accept"
25
+ # id2 = generator.generate_id("Accept") # => "accept_a1b2c3"
26
+ #
27
+ # @example Special characters
28
+ # generator = IdGenerator.new
29
+ # id = generator.generate_id("Yes! 👍 (recommended)") # => "yes_recommended"
30
+ #
31
+ class IdGenerator
32
+ MAX_ID_LENGTH = 256
33
+ HASH_SUFFIX_LENGTH = 3
34
+
35
+ def initialize
36
+ @generated_ids = []
37
+ end
38
+
39
+ # Generate a WhatsApp-safe ID from a label
40
+ #
41
+ # @param label [String] The choice label to convert
42
+ # @return [String] A sanitized, unique ID
43
+ def generate_id(label)
44
+ # Normalize the label
45
+ normalized = normalize_label(label)
46
+
47
+ # If normalized label is empty, use a fallback
48
+ normalized = "choice" if normalized.empty?
49
+
50
+ # Truncate to limit first
51
+ truncated = truncate_to_limit(normalized)
52
+
53
+ # Check if we need a hash suffix for uniqueness
54
+ final_id = if @generated_ids.include?(truncated)
55
+ add_hash_suffix(truncated, label)
56
+ else
57
+ truncated
58
+ end
59
+
60
+ # Track this ID
61
+ @generated_ids << final_id
62
+
63
+ final_id
64
+ end
65
+
66
+ # Reset the generator state (useful for testing or starting a new message)
67
+ def reset
68
+ @generated_ids = []
69
+ end
70
+
71
+ # Get all generated IDs (useful for debugging)
72
+ def generated_ids
73
+ @generated_ids.dup
74
+ end
75
+
76
+ private
77
+
78
+ # Normalize a label into a WhatsApp-safe identifier
79
+ # Keeps readability by preserving spaces and basic punctuation
80
+ #
81
+ # @param label [String] The original label
82
+ # @return [String] Normalized identifier
83
+ def normalize_label(label)
84
+ label
85
+ .to_s
86
+ .gsub(/[^\w\s\-']/, "") # remove special chars (keep word chars, spaces, hyphens, apostrophes)
87
+ .gsub(/\s+/, " ") # collapse multiple spaces to single space (after removing chars)
88
+ .strip # trim leading/trailing whitespace
89
+ end
90
+
91
+ # Add a hash suffix to make the ID unique
92
+ #
93
+ # The hash is generated from the original label to ensure
94
+ # the same label always produces the same hash.
95
+ #
96
+ # @param base_id [String] The base identifier
97
+ # @param original_label [String] The original label for hash generation
98
+ # @return [String] ID with hash suffix
99
+ def add_hash_suffix(base_id, original_label)
100
+ # Generate a short hash from the original label + timestamp for uniqueness
101
+ # We use the current generated_ids count to ensure different duplicates get different hashes
102
+ hash_input = "#{original_label}_#{@generated_ids.count { |id| id.start_with?(base_id) }}"
103
+ hash = Digest::SHA256.hexdigest(hash_input)[0...HASH_SUFFIX_LENGTH]
104
+
105
+ # Calculate max base length to fit: base + "_" + hash
106
+ max_base_length = MAX_ID_LENGTH - HASH_SUFFIX_LENGTH - 1
107
+
108
+ # Truncate base if needed
109
+ truncated_base = base_id[0...max_base_length]
110
+
111
+ "#{truncated_base} #{hash}"
112
+ end
113
+
114
+ # Truncate ID to maximum allowed length
115
+ #
116
+ # @param id [String] The ID to truncate
117
+ # @return [String] Truncated ID
118
+ def truncate_to_limit(id)
119
+ return id if id.length <= MAX_ID_LENGTH
120
+ id[0...MAX_ID_LENGTH]
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,147 @@
1
+ require_relative "../id_generator"
2
+
3
+ module FlowChat
4
+ module Whatsapp
5
+ module Middleware
6
+ # Maps WhatsApp button/list IDs back to original choice keys
7
+ #
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.
11
+ #
12
+ # 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)
21
+ #
22
+ # @example
23
+ # # 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"
29
+ #
30
+ # # 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"
36
+ #
37
+ class ChoiceMapper
38
+ def initialize(app)
39
+ @app = app
40
+ FlowChat.logger.debug { "Whatsapp::ChoiceMapper: Initialized WhatsApp choice mapping middleware" }
41
+ end
42
+
43
+ def call(context)
44
+ @context = context
45
+ @session = context.session
46
+
47
+ session_id = context["session.id"]
48
+ FlowChat.logger.debug { "Whatsapp::ChoiceMapper: Processing request for session #{session_id}" }
49
+
50
+ if intercept?
51
+ FlowChat.logger.info { "Whatsapp::ChoiceMapper: Intercepting request for choice resolution - session #{session_id}" }
52
+ handle_choice_input
53
+ end
54
+
55
+ # Clear choice mapping state for new flows
56
+ clear_choice_state_if_needed
57
+
58
+ # Call the app (executor -> flow)
59
+ type, prompt, choices, media = @app.call(context)
60
+
61
+ # Transform choices if present (like USSD does)
62
+ if choices.present?
63
+ FlowChat.logger.debug { "Whatsapp::ChoiceMapper: Found choices, creating ID mapping" }
64
+ choices = create_id_mapping(choices)
65
+ end
66
+
67
+ [type, prompt, choices, media]
68
+ end
69
+
70
+ private
71
+
72
+ 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)
78
+
79
+ if should_intercept
80
+ FlowChat.logger.debug { "Whatsapp::ChoiceMapper: Intercepting - input: #{@context.input}, mapped to: #{choice_mapping[@context.input.to_s]}" }
81
+ end
82
+
83
+ should_intercept
84
+ end
85
+
86
+ def handle_choice_input
87
+ choice_mapping = get_choice_mapping
88
+ original_choice = choice_mapping[@context.input.to_s]
89
+
90
+ FlowChat.logger.info { "Whatsapp::ChoiceMapper: Resolving choice input #{@context.input} to #{original_choice}" }
91
+
92
+ # Replace the generated ID with the original choice key
93
+ @context.input = original_choice
94
+ end
95
+
96
+ def create_id_mapping(choices)
97
+ # Choices are always a hash after normalize_choices
98
+ id_generator = IdGenerator.new
99
+ id_choices = {}
100
+ choice_mapping = {}
101
+
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
107
+ end
108
+
109
+ store_choice_mapping(choice_mapping)
110
+ FlowChat.logger.debug { "Whatsapp::ChoiceMapper: Created mapping: #{choice_mapping}" }
111
+ id_choices
112
+ end
113
+
114
+ def store_choice_mapping(mapping)
115
+ @session.set("whatsapp.choice_mapping", mapping)
116
+ FlowChat.logger.debug { "Whatsapp::ChoiceMapper: Stored choice mapping: #{mapping}" }
117
+ end
118
+
119
+ def get_choice_mapping
120
+ @session.get("whatsapp.choice_mapping") || {}
121
+ end
122
+
123
+ def clear_choice_mapping
124
+ @session.delete("whatsapp.choice_mapping")
125
+ FlowChat.logger.debug { "Whatsapp::ChoiceMapper: Cleared choice mapping" }
126
+ end
127
+
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
133
+ end
134
+
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?
140
+
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)
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
@@ -1,6 +1,10 @@
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
+
4
8
  attr_reader :message, :choices, :media
5
9
 
6
10
  def initialize(message, choices: nil, media: nil)
@@ -24,25 +28,60 @@ module FlowChat
24
28
  private
25
29
 
26
30
  def build_text_message
27
- [:text, message, {}]
31
+ [:text, to_whatsapp(message), {}]
32
+ end
33
+
34
+ def formatted_message
35
+ to_whatsapp(message)
36
+ end
37
+
38
+ def formatted_caption
39
+ message.present? ? to_whatsapp(message) : nil
28
40
  end
29
41
 
30
42
  def build_media_message
31
43
  media_type = media[:type] || :image
32
- url = media[:url] || media[:path]
44
+ url = media[:url]
45
+ id = media[:id]
33
46
  filename = media[:filename]
34
47
 
35
48
  case media_type.to_sym
36
49
  when :image
37
- [:media_image, "", {url: url, caption: message}]
50
+ options = {}
51
+ options[:url] = url if url
52
+ options[:id] = id if id
53
+ options[:caption] = formatted_caption if formatted_caption
54
+ [:media_image, "", options]
38
55
  when :document
39
- [:media_document, "", {url: url, caption: message, filename: filename}]
56
+ options = {}
57
+ options[:url] = url if url
58
+ options[:id] = id if id
59
+ options[:caption] = formatted_caption if formatted_caption
60
+ options[:filename] = filename if filename
61
+ [:media_document, "", options]
40
62
  when :audio
41
- [:media_audio, "", {url: url, caption: message}]
63
+ options = {}
64
+ options[:url] = url if url
65
+ options[:id] = id if id
66
+ options[:caption] = formatted_caption if formatted_caption
67
+ [:media_audio, "", options]
42
68
  when :video
43
- [:media_video, "", {url: url, caption: message}]
69
+ options = {}
70
+ options[:url] = url if url
71
+ options[:id] = id if id
72
+ options[:caption] = formatted_caption if formatted_caption
73
+ [:media_video, "", options]
44
74
  when :sticker
45
- [:media_sticker, "", {url: url}] # Stickers don't support captions
75
+ options = {}
76
+ options[:url] = url if url
77
+ options[:id] = id if id
78
+ [:media_sticker, "", options] # Stickers don't support captions
79
+ when :template
80
+ [:template, "", {
81
+ template_name: media[:template_name],
82
+ components: media[:components] || [],
83
+ language: media[:language] || "en_US"
84
+ }]
46
85
  else
47
86
  raise ArgumentError, "Unsupported media type: #{media_type}"
48
87
  end
@@ -86,7 +125,7 @@ module FlowChat
86
125
  }
87
126
  end
88
127
 
89
- [:interactive_buttons, message, {buttons: buttons}]
128
+ [:interactive_buttons, formatted_message, {buttons: buttons}]
90
129
  end
91
130
 
92
131
  def build_buttons_message_with_media(choices)
@@ -100,12 +139,12 @@ module FlowChat
100
139
  # Build media header
101
140
  header = build_media_header
102
141
 
103
- [:interactive_buttons, message, {buttons: buttons, header: header}]
142
+ [:interactive_buttons, formatted_message, {buttons: buttons, header: header}]
104
143
  end
105
144
 
106
145
  def build_media_header
107
146
  media_type = media[:type] || :image
108
- url = media[:url] || media[:path]
147
+ url = media[:url]
109
148
  filename = media[:filename]
110
149
 
111
150
  case media_type.to_sym
@@ -174,13 +213,108 @@ module FlowChat
174
213
  end
175
214
  end
176
215
 
177
- [:interactive_list, message, {sections: sections}]
216
+ [:interactive_list, formatted_message, {sections: sections}]
178
217
  end
179
218
 
180
219
  def truncate_text(text, length)
181
220
  return text if text.length <= length
182
221
  text[0, length - 3] + "..."
183
222
  end
223
+
224
+ # Convert text to WhatsApp format
225
+ # Processes markdown through HTML, then converts HTML tags to WhatsApp syntax
226
+ def to_whatsapp(text)
227
+ return "" if text.nil?
228
+
229
+ # Pre-process: handle markdown features not supported by standard kramdown
230
+ processed = preprocess_markdown(text.to_s)
231
+
232
+ # Convert markdown to HTML
233
+ html = Kramdown::Document.new(processed, **kramdown_options).to_html.strip
234
+ html_to_whatsapp(html)
235
+ end
236
+
237
+ # Handle markdown features not natively supported by kramdown
238
+ def preprocess_markdown(text)
239
+ result = text.dup
240
+
241
+ # Convert fenced code blocks to indented code blocks (kramdown native format)
242
+ # ```lang\ncode\n``` → indented with 4 spaces
243
+ result.gsub!(/^```\w*\n(.*?)^```/m) do
244
+ code = $1
245
+ code.lines.map { |line| " #{line}" }.join
246
+ end
247
+
248
+ # Convert ~~strikethrough~~ to HTML <del> tags (kramdown will pass through)
249
+ result.gsub!(/~~([^~]+)~~/, '<del>\1</del>')
250
+
251
+ result
252
+ end
253
+
254
+ # Convert HTML to WhatsApp formatting syntax
255
+ def html_to_whatsapp(html)
256
+ result = html.dup
257
+
258
+ # Convert code blocks first (before inline code)
259
+ # <pre><code>...</code></pre> → ```...```
260
+ result.gsub!(%r{<pre[^>]*><code[^>]*>(.*?)</code></pre>}m) { "```#{$1}```" }
261
+
262
+ # Convert inline code: <code>...</code> → `...`
263
+ result.gsub!(%r{<code[^>]*>(.*?)</code>}m) { "`#{$1}`" }
264
+
265
+ # Convert bold: <strong>...</strong> or <b>...</b> → *...*
266
+ result.gsub!(%r{<(?:strong|b)[^>]*>(.*?)</(?:strong|b)>}m) { "*#{$1}*" }
267
+
268
+ # Convert italic: <em>...</em> or <i>...</i> → _..._
269
+ result.gsub!(%r{<(?:em|i)[^>]*>(.*?)</(?:em|i)>}m) { "_#{$1}_" }
270
+
271
+ # Convert strikethrough: <s>...</s>, <del>...</del>, <strike>...</strike> → ~...~
272
+ result.gsub!(%r{<(?:s|del|strike)[^>]*>(.*?)</(?:s|del|strike)>}m) { "~#{$1}~" }
273
+
274
+ # Convert paragraphs to double newlines
275
+ result.gsub!(%r{<p[^>]*>(.*?)</p>}m) { "#{$1}\n\n" }
276
+
277
+ # Convert line breaks
278
+ result.gsub!(/<br\s*\/?>/, "\n")
279
+
280
+ # Convert links: <a href="url">text</a> → text (url)
281
+ # WhatsApp auto-links URLs, so we just show text and URL
282
+ result.gsub!(%r{<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)</a>}m) do
283
+ url, text = $1, $2
284
+ (text == url) ? url : "#{text} (#{url})"
285
+ end
286
+
287
+ # Convert blockquotes (WhatsApp doesn't have native support, use > prefix)
288
+ result.gsub!(%r{<blockquote[^>]*>(.*?)</blockquote>}m) do
289
+ $1.lines.map { |line| "> #{line.strip}" }.join("\n")
290
+ end
291
+
292
+ # Convert lists
293
+ result.gsub!(%r{<ul[^>]*>(.*?)</ul>}m) do
294
+ items = $1.scan(%r{<li[^>]*>(.*?)</li>}m).flatten
295
+ items.map { |item| "• #{item.strip}" }.join("\n")
296
+ end
297
+ result.gsub!(%r{<ol[^>]*>(.*?)</ol>}m) do
298
+ items = $1.scan(%r{<li[^>]*>(.*?)</li>}m).flatten
299
+ items.map.with_index(1) { |item, i| "#{i}. #{item.strip}" }.join("\n")
300
+ end
301
+
302
+ # Strip any remaining HTML tags
303
+ result.gsub!(/<[^>]+>/, "")
304
+
305
+ # Decode HTML entities
306
+ result.gsub!("&amp;", "&")
307
+ result.gsub!("&lt;", "<")
308
+ result.gsub!("&gt;", ">")
309
+ result.gsub!("&quot;", '"')
310
+ result.gsub!("&#39;", "'")
311
+ result.gsub!("&nbsp;", " ")
312
+
313
+ # Clean up excessive newlines
314
+ result.gsub!(/\n{3,}/, "\n\n")
315
+
316
+ result.strip
317
+ end
184
318
  end
185
319
  end
186
320
  end
data/lib/flow_chat.rb CHANGED
@@ -10,6 +10,16 @@ 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
+
13
23
  def self.root
14
24
  Pathname.new __dir__
15
25
  end
@@ -26,4 +36,4 @@ module FlowChat
26
36
  def self.metrics
27
37
  FlowChat::Instrumentation::Setup.metrics_collector
28
38
  end
29
- end
39
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Release flow
4
+ # ------------
5
+ # Publishing happens from a laptop. CI does NOT push to any registry — it only
6
+ # cuts the GitHub Release (with notes + the built gem) when the tag lands.
7
+ #
8
+ # 1. rake release:prepare # auto-computes next version via git-cliff
9
+ # rake release:prepare[1.2.3] # ...or pass one explicitly
10
+ # # → bumps + regenerates changelog, STAGES
11
+ # # the changes, and shows the diff. Nothing
12
+ # # is committed.
13
+ # 2. git diff --cached # review the staged changes
14
+ # 3. rake release:publish # commits, publishes the gem, then tags + pushes
15
+ # # → CI cuts the Release from the tag
16
+ #
17
+ # To abort after prepare: git reset --hard (discards the staged changes).
18
+ # release:publish is idempotent and resumable: it skips a gem already live and
19
+ # only tags if the tag is missing, so a partial failure can just be re-run.
20
+
21
+ RELEASE_CLIFF_CONFIG = ".cliff.toml"
22
+ RELEASE_VERSION_FILE = "lib/flow_chat/version.rb"
23
+ RELEASE_GEM_NAME = "flow_chat"
24
+ RELEASE_SECURITY_FILE = "SECURITY.md"
25
+
26
+ namespace :release do
27
+ # --- helpers --------------------------------------------------------------
28
+
29
+ def current_version
30
+ File.read(RELEASE_VERSION_FILE)[/VERSION = "([\d.]+)"/, 1] ||
31
+ abort("Could not read VERSION from #{RELEASE_VERSION_FILE}")
32
+ end
33
+
34
+ def git_cliff?
35
+ system("which git-cliff > /dev/null 2>&1")
36
+ end
37
+
38
+ # Next version per conventional commits. git-cliff owns the semver math
39
+ # (including the pre-1.0 rules configured under [bump] in .cliff.toml).
40
+ def computed_next_version
41
+ abort "git-cliff not found. Install with: brew install git-cliff" unless git_cliff?
42
+ bumped = `git-cliff --config #{RELEASE_CLIFF_CONFIG} --bumped-version 2>/dev/null`.strip
43
+ abort "git-cliff could not compute a version (no conventional commits since last tag?)" if bumped.empty?
44
+ bumped.delete_prefix("v")
45
+ end
46
+
47
+ def gem_published?(version)
48
+ out = `gem list --remote --exact --all #{RELEASE_GEM_NAME} 2>/dev/null`
49
+ out.include?("#{version},") || out.include?("#{version})") || out.include?(" #{version} ")
50
+ end
51
+
52
+ # --- version --------------------------------------------------------------
53
+
54
+ desc "Show the next version computed from conventional commits"
55
+ task :version do
56
+ puts "Current version: #{current_version}"
57
+ puts "Next version: #{computed_next_version}"
58
+ end
59
+
60
+ # --- prepare --------------------------------------------------------------
61
+
62
+ desc "Stage a release (bump + changelog) for review. Version optional; git-cliff computes it."
63
+ task :prepare, [:version] do |_t, args|
64
+ version = args[:version] || computed_next_version
65
+
66
+ unless version.match?(/^\d+\.\d+\.\d+$/)
67
+ abort "Error: version must be in format X.Y.Z (got #{version.inspect})"
68
+ end
69
+
70
+ unless `git status --porcelain`.strip.empty?
71
+ abort "Error: working tree is dirty. Commit or stash first."
72
+ end
73
+
74
+ puts "Preparing release v#{version}..."
75
+
76
+ # Bump version.rb
77
+ content = File.read(RELEASE_VERSION_FILE)
78
+ File.write(RELEASE_VERSION_FILE, content.gsub(/VERSION = "[\d.]+"/, %(VERSION = "#{version}")))
79
+ puts "✓ #{RELEASE_VERSION_FILE}"
80
+
81
+ # Bump the supported-version series in SECURITY.md (e.g. `0.8.x`), so the
82
+ # policy always names the current release series. Skipped if the file or the
83
+ # expected `X.Y.x` marker is missing — a release must never fail over this.
84
+ if File.exist?(RELEASE_SECURITY_FILE)
85
+ series = "#{version.split(".").first(2).join(".")}.x"
86
+ security = File.read(RELEASE_SECURITY_FILE)
87
+ updated = security.gsub(/`\d+\.\d+\.x`/, "`#{series}`")
88
+ if updated == security
89
+ puts "• #{RELEASE_SECURITY_FILE} — no `X.Y.x` marker found, skipping"
90
+ else
91
+ File.write(RELEASE_SECURITY_FILE, updated)
92
+ puts "✓ #{RELEASE_SECURITY_FILE}"
93
+ end
94
+ end
95
+
96
+ # Changelog — same config CI uses for release notes, so they agree.
97
+ abort "git-cliff not found. Install with: brew install git-cliff" unless git_cliff?
98
+ system("git-cliff", "--config", RELEASE_CLIFF_CONFIG, "--tag", "v#{version}", "-o", "CHANGELOG.md") ||
99
+ abort("Changelog generation failed")
100
+ puts "✓ CHANGELOG.md"
101
+
102
+ # Stage everything and show it — review happens BEFORE anything is committed.
103
+ system("git", "add", "-A") || abort("git add failed")
104
+
105
+ puts "\n✓ Staged release v#{version} (nothing committed yet)."
106
+ puts "\nStaged changes:"
107
+ system("git", "--no-pager", "diff", "--cached", "--stat")
108
+ puts "\nNext:"
109
+ puts " git diff --cached # review the full diff"
110
+ puts " rake release:publish # commit, publish gem, tag + push"
111
+ puts " git reset --hard # abort and discard the staged changes"
112
+ end
113
+
114
+ # --- publish (primary; idempotent + resumable) ----------------------------
115
+
116
+ desc "Commit the prepared release, publish the gem, then tag + push (fires the Release workflow)"
117
+ task :publish do
118
+ version = current_version
119
+ tag = "v#{version}"
120
+
121
+ # Commit the changes prepare left staged for review. If the tree is already
122
+ # clean (e.g. re-running after a partial failure), there's nothing to commit.
123
+ if `git status --porcelain`.strip.empty?
124
+ puts "• working tree clean — nothing to commit"
125
+ else
126
+ system("git", "add", "-A") || abort("git add failed")
127
+ system("git", "commit", "-m", "chore(release): prepare for v#{version}") || abort("git commit failed")
128
+ puts "✓ Committed release v#{version}"
129
+ end
130
+
131
+ # Gem (skip if this version is already on RubyGems)
132
+ if gem_published?(version)
133
+ puts "• gem #{RELEASE_GEM_NAME} #{version} already on RubyGems — skipping"
134
+ else
135
+ puts "Building + pushing gem..."
136
+ system("gem build #{RELEASE_GEM_NAME}.gemspec") || abort("Gem build failed")
137
+ gem_file = "#{RELEASE_GEM_NAME}-#{version}.gem"
138
+ system("gem push #{gem_file}") || abort("Gem push failed")
139
+ File.delete(gem_file) if File.exist?(gem_file)
140
+ puts "✓ Published #{RELEASE_GEM_NAME} #{version} to RubyGems"
141
+ end
142
+
143
+ # Tag + push last, so CI cuts the Release only once the gem is live.
144
+ branch = `git branch --show-current`.strip
145
+ if system("git rev-parse #{tag} >/dev/null 2>&1")
146
+ puts "• tag #{tag} already exists — skipping tag"
147
+ else
148
+ system("git", "tag", tag) || abort("git tag failed")
149
+ end
150
+ system("git", "push", "origin", branch) || abort("git push branch failed")
151
+ system("git", "push", "origin", tag) || abort("git push tag failed")
152
+
153
+ puts "\n✓ Released #{tag}. GitHub Actions will cut the Release from the tag."
154
+ puts " Watch: https://github.com/radioactive-labs/flow_chat/actions"
155
+ end
156
+ end
157
+
158
+ # Neutralize the dangerous bare `rake release` that bundler/gem_tasks defines
159
+ # (it would tag + gem push directly). Point people at the real flow instead.
160
+ if Rake::Task.task_defined?("release")
161
+ Rake::Task["release"].clear
162
+ task :release do
163
+ warn "Use `rake release:prepare` then `rake release:publish`. See lib/tasks/release.rake."
164
+ end
165
+ end