flow_chat 0.8.1 → 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 +154 -0
  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/simulator_controller.rb +21 -1
  31. data/examples/ussd_controller.rb +10 -10
  32. data/examples/whatsapp_controller.rb +2 -2
  33. data/flow_chat.gemspec +4 -0
  34. data/lib/flow_chat/{base_app.rb → app.rb} +28 -9
  35. data/lib/flow_chat/async_job.rb +176 -0
  36. data/lib/flow_chat/config.rb +23 -28
  37. data/lib/flow_chat/{base_executor.rb → executor.rb} +7 -12
  38. data/lib/flow_chat/factory.rb +94 -0
  39. data/lib/flow_chat/gateway_async_support.rb +106 -0
  40. data/lib/flow_chat/generic_async_job.rb +30 -0
  41. data/lib/flow_chat/http/gateway/simple.rb +125 -0
  42. data/lib/flow_chat/http/renderer.rb +41 -0
  43. data/lib/flow_chat/instrumentation/setup.rb +1 -1
  44. data/lib/flow_chat/instrumentation.rb +25 -0
  45. data/lib/flow_chat/intercom/client.rb +155 -0
  46. data/lib/flow_chat/intercom/configuration.rb +149 -0
  47. data/lib/flow_chat/intercom/gateway/intercom_api.rb +396 -0
  48. data/lib/flow_chat/intercom/renderer.rb +71 -0
  49. data/lib/flow_chat/phone_number_util.rb +49 -0
  50. data/lib/flow_chat/processor.rb +188 -0
  51. data/lib/flow_chat/renderers/markdown_support.rb +58 -0
  52. data/lib/flow_chat/session/cache_session_store.rb +1 -17
  53. data/lib/flow_chat/session/middleware.rb +43 -26
  54. data/lib/flow_chat/simulator/controller.rb +17 -5
  55. data/lib/flow_chat/simulator/views/simulator.html.erb +220 -8
  56. data/lib/flow_chat/telegram/client.rb +240 -0
  57. data/lib/flow_chat/telegram/configuration.rb +118 -0
  58. data/lib/flow_chat/telegram/gateway/bot_api.rb +300 -0
  59. data/lib/flow_chat/telegram/middleware/choice_mapper.rb +35 -0
  60. data/lib/flow_chat/telegram/renderer.rb +125 -0
  61. data/lib/flow_chat/telegram.rb +7 -0
  62. data/lib/flow_chat/ussd/gateway/nalo.rb +27 -11
  63. data/lib/flow_chat/ussd/middleware/pagination.rb +9 -5
  64. data/lib/flow_chat/ussd/renderer.rb +1 -1
  65. data/lib/flow_chat/version.rb +1 -1
  66. data/lib/flow_chat/whatsapp/client.rb +144 -13
  67. data/lib/flow_chat/whatsapp/configuration.rb +1 -1
  68. data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +144 -106
  69. data/lib/flow_chat/whatsapp/id_generator.rb +124 -0
  70. data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +147 -0
  71. data/lib/flow_chat/whatsapp/renderer.rb +145 -11
  72. data/lib/flow_chat.rb +11 -1
  73. data/lib/tasks/release.rake +165 -0
  74. metadata +88 -21
  75. data/docs/flows.md +0 -320
  76. data/docs/images/simulator.png +0 -0
  77. data/docs/media.md +0 -153
  78. data/docs/sessions.md +0 -433
  79. data/docs/ussd-setup.md +0 -322
  80. data/docs/whatsapp-setup.md +0 -162
  81. data/examples/whatsapp_message_job.rb +0 -113
  82. data/lib/flow_chat/base_processor.rb +0 -145
  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 -98
  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,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