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.
- checksums.yaml +4 -4
- data/.cliff.toml +74 -0
- data/.github/workflows/ci.yml +2 -3
- data/.github/workflows/release.yml +56 -0
- data/.standard.yml +4 -0
- data/CHANGELOG.md +22 -0
- data/CLAUDE.md +327 -0
- data/CONTRIBUTING.md +134 -0
- data/Gemfile +1 -0
- data/README.md +290 -105
- data/Rakefile +5 -1
- data/SECURITY.md +42 -349
- data/docs/architecture.md +510 -0
- data/docs/async-background-processing.md +298 -0
- data/docs/configuration.md +556 -226
- data/docs/factory-pattern.md +355 -0
- data/docs/gateway-context-variables.md +171 -0
- data/docs/gateway-development.md +723 -0
- data/docs/getting-started.md +429 -0
- data/docs/instrumentation.md +264 -153
- data/docs/platforms/telegram.md +1013 -0
- data/docs/platforms/ussd.md +693 -0
- data/docs/platforms/whatsapp.md +1395 -0
- data/docs/testing.md +243 -365
- data/examples/custom_session_id_example.rb +119 -0
- data/examples/http_controller.rb +154 -0
- data/examples/intercom_configuration_example.rb +118 -0
- data/examples/intercom_controller.rb +194 -0
- data/examples/multi_tenant_whatsapp_controller.rb +4 -4
- data/examples/simulator_controller.rb +21 -1
- data/examples/ussd_controller.rb +10 -10
- data/examples/whatsapp_controller.rb +2 -2
- data/flow_chat.gemspec +4 -0
- data/lib/flow_chat/{base_app.rb → app.rb} +28 -9
- data/lib/flow_chat/async_job.rb +176 -0
- data/lib/flow_chat/config.rb +23 -28
- data/lib/flow_chat/{base_executor.rb → executor.rb} +7 -12
- data/lib/flow_chat/factory.rb +94 -0
- data/lib/flow_chat/gateway_async_support.rb +106 -0
- data/lib/flow_chat/generic_async_job.rb +30 -0
- data/lib/flow_chat/http/gateway/simple.rb +125 -0
- data/lib/flow_chat/http/renderer.rb +41 -0
- data/lib/flow_chat/instrumentation/setup.rb +1 -1
- data/lib/flow_chat/instrumentation.rb +25 -0
- data/lib/flow_chat/intercom/client.rb +155 -0
- data/lib/flow_chat/intercom/configuration.rb +149 -0
- data/lib/flow_chat/intercom/gateway/intercom_api.rb +396 -0
- data/lib/flow_chat/intercom/renderer.rb +71 -0
- data/lib/flow_chat/phone_number_util.rb +49 -0
- data/lib/flow_chat/processor.rb +188 -0
- data/lib/flow_chat/renderers/markdown_support.rb +58 -0
- data/lib/flow_chat/session/cache_session_store.rb +1 -17
- data/lib/flow_chat/session/middleware.rb +43 -26
- data/lib/flow_chat/simulator/controller.rb +17 -5
- data/lib/flow_chat/simulator/views/simulator.html.erb +220 -8
- data/lib/flow_chat/telegram/client.rb +240 -0
- data/lib/flow_chat/telegram/configuration.rb +118 -0
- data/lib/flow_chat/telegram/gateway/bot_api.rb +300 -0
- data/lib/flow_chat/telegram/middleware/choice_mapper.rb +35 -0
- data/lib/flow_chat/telegram/renderer.rb +125 -0
- data/lib/flow_chat/telegram.rb +7 -0
- data/lib/flow_chat/ussd/gateway/nalo.rb +27 -11
- data/lib/flow_chat/ussd/middleware/pagination.rb +9 -5
- data/lib/flow_chat/ussd/renderer.rb +1 -1
- data/lib/flow_chat/version.rb +1 -1
- data/lib/flow_chat/whatsapp/client.rb +144 -13
- data/lib/flow_chat/whatsapp/configuration.rb +1 -1
- data/lib/flow_chat/whatsapp/gateway/cloud_api.rb +144 -106
- data/lib/flow_chat/whatsapp/id_generator.rb +124 -0
- data/lib/flow_chat/whatsapp/middleware/choice_mapper.rb +147 -0
- data/lib/flow_chat/whatsapp/renderer.rb +145 -11
- data/lib/flow_chat.rb +11 -1
- data/lib/tasks/release.rake +165 -0
- metadata +88 -21
- data/docs/flows.md +0 -320
- data/docs/images/simulator.png +0 -0
- data/docs/media.md +0 -153
- data/docs/sessions.md +0 -433
- data/docs/ussd-setup.md +0 -322
- data/docs/whatsapp-setup.md +0 -162
- data/examples/whatsapp_message_job.rb +0 -113
- data/lib/flow_chat/base_processor.rb +0 -145
- data/lib/flow_chat/session/rails_session_store.rb +0 -68
- data/lib/flow_chat/ussd/app.rb +0 -6
- data/lib/flow_chat/ussd/gateway/nsano.rb +0 -98
- data/lib/flow_chat/ussd/middleware/executor.rb +0 -24
- data/lib/flow_chat/ussd/processor.rb +0 -39
- data/lib/flow_chat/whatsapp/app.rb +0 -29
- data/lib/flow_chat/whatsapp/middleware/executor.rb +0 -24
- data/lib/flow_chat/whatsapp/processor.rb +0 -32
- 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]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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]
|
|
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,
|
|
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!("&", "&")
|
|
307
|
+
result.gsub!("<", "<")
|
|
308
|
+
result.gsub!(">", ">")
|
|
309
|
+
result.gsub!(""", '"')
|
|
310
|
+
result.gsub!("'", "'")
|
|
311
|
+
result.gsub!(" ", " ")
|
|
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
|