collavre 0.20.2 → 0.21.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/app/assets/stylesheets/collavre/actiontext.css +92 -2
- data/app/assets/stylesheets/collavre/code_highlight.css +144 -26
- data/app/assets/stylesheets/collavre/comments_popup.css +83 -0
- data/app/assets/stylesheets/collavre/landing.css +507 -0
- data/app/channels/collavre/comments_presence_channel.rb +7 -0
- data/app/controllers/collavre/admin/integrations_controller.rb +82 -0
- data/app/controllers/collavre/admin/settings_controller.rb +22 -17
- data/app/controllers/collavre/application_controller.rb +27 -0
- data/app/controllers/collavre/channels_controller.rb +23 -0
- data/app/controllers/collavre/creatives_controller.rb +50 -6
- data/app/controllers/collavre/landing_controller.rb +8 -0
- data/app/controllers/collavre/passwords_controller.rb +1 -0
- data/app/controllers/collavre/public_assets_controller.rb +24 -0
- data/app/controllers/collavre/topics_controller.rb +21 -30
- data/app/helpers/collavre/comments_helper.rb +7 -0
- data/app/helpers/collavre/public_assets_helper.rb +14 -0
- data/app/javascript/controllers/comment_controller.js +9 -0
- data/app/javascript/controllers/comments/form_controller.js +4 -0
- data/app/javascript/controllers/comments/list_controller.js +10 -7
- data/app/javascript/controllers/comments/popup_controller.js +9 -0
- data/app/javascript/controllers/comments/presence_controller.js +83 -1
- data/app/javascript/controllers/comments/topics_controller.js +15 -0
- data/app/javascript/controllers/creatives/__tests__/sync_controller.test.js +89 -0
- data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +120 -0
- data/app/javascript/controllers/creatives/sync_controller.js +30 -9
- data/app/javascript/controllers/creatives/tree_controller.js +23 -0
- data/app/javascript/controllers/index.js +4 -1
- data/app/javascript/controllers/landing_video_controller.js +53 -0
- data/app/javascript/creatives/tree_renderer.js +6 -0
- data/app/javascript/lib/api/__tests__/queue_manager.test.js +27 -0
- data/app/javascript/lib/api/queue_manager.js +17 -5
- data/app/javascript/modules/__tests__/command_args_form.test.js +103 -0
- data/app/javascript/modules/__tests__/html_content_empty.test.js +41 -0
- data/app/javascript/modules/__tests__/markdown_source_reconcile.test.js +70 -0
- data/app/javascript/modules/command_args_form.js +22 -4
- data/app/javascript/modules/command_menu.js +27 -0
- data/app/javascript/modules/creative_row_editor.js +227 -17
- data/app/javascript/modules/html_content_empty.js +12 -0
- data/app/javascript/modules/markdown_source_reconcile.js +53 -0
- data/app/jobs/collavre/drop_trigger_job.rb +37 -8
- data/app/mailers/collavre/application_mailer.rb +1 -1
- data/app/models/collavre/channel/injected_message.rb +5 -0
- data/app/models/collavre/channel.rb +87 -0
- data/app/models/collavre/creative/describable.rb +65 -3
- data/app/models/collavre/creative.rb +2 -0
- data/app/models/collavre/integration_setting.rb +35 -0
- data/app/models/collavre/preview_channel.rb +93 -0
- data/app/models/collavre/system_setting.rb +13 -2
- data/app/models/collavre/topic.rb +3 -25
- data/app/models/concerns/collavre/ai_agent_resolvable.rb +12 -3
- data/app/services/collavre/ai_client.rb +3 -3
- data/app/services/collavre/channel_attacher.rb +58 -0
- data/app/services/collavre/comments/mcp_command.rb +31 -1
- data/app/services/collavre/creatives/tree_builder.rb +7 -3
- data/app/services/collavre/google_calendar_service.rb +4 -2
- data/app/services/collavre/markdown_converter.rb +130 -15
- data/app/services/collavre/markdown_importer.rb +7 -2
- data/app/services/collavre/orchestration/policy_resolver.rb +11 -1
- data/app/services/collavre/tools/creative_attach_files_service.rb +96 -0
- data/app/services/collavre/tools/creative_list_attachments_service.rb +42 -0
- data/app/services/collavre/tools/creative_remove_attachment_service.rb +35 -0
- data/app/services/collavre/tools/permission_denied_error.rb +9 -0
- data/app/services/collavre/tools/preview_attach_service.rb +128 -0
- data/app/services/collavre/tools/preview_detach_service.rb +61 -0
- data/app/services/collavre/tools/topic_authorizer.rb +24 -0
- data/app/services/collavre/topic_branch_service.rb +34 -26
- data/app/views/admin/shared/_tabs.html.erb +1 -0
- data/app/views/collavre/admin/integrations/_category.html.erb +22 -0
- data/app/views/collavre/admin/integrations/_setting_row.html.erb +54 -0
- data/app/views/collavre/admin/integrations/index.html.erb +42 -0
- data/app/views/collavre/admin/settings/_system_tab.html.erb +8 -0
- data/app/views/collavre/comments/_channel_chips.html.erb +33 -0
- data/app/views/collavre/comments/_comment.html.erb +6 -1
- data/app/views/collavre/comments/_comments_popup.html.erb +1 -0
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +19 -0
- data/app/views/collavre/creatives/index.html.erb +10 -2
- data/app/views/collavre/landing/show.html.erb +130 -0
- data/app/views/layouts/collavre/landing.html.erb +33 -0
- data/config/locales/admin.en.yml +4 -2
- data/config/locales/admin.ko.yml +4 -2
- data/config/locales/channels.en.yml +11 -0
- data/config/locales/channels.ko.yml +11 -0
- data/config/locales/comments.en.yml +2 -0
- data/config/locales/comments.ko.yml +2 -0
- data/config/locales/creatives.en.yml +9 -0
- data/config/locales/creatives.ko.yml +8 -0
- data/config/locales/integrations.en.yml +44 -0
- data/config/locales/integrations.ko.yml +44 -0
- data/config/locales/landing.en.yml +51 -0
- data/config/locales/landing.ko.yml +51 -0
- data/config/routes.rb +18 -0
- data/db/migrate/20260526000000_create_channels.rb +42 -0
- data/db/migrate/20260527000000_add_dismissed_at_to_channels.rb +6 -0
- data/db/migrate/20260527000100_backfill_dismissed_at_for_legacy_detached_channels.rb +28 -0
- data/db/migrate/20260528000000_add_preview_channel_unique_index.rb +31 -0
- data/db/migrate/20260529000000_add_primary_agent_id_to_topics.rb +40 -0
- data/db/migrate/20260529100000_create_integration_settings.rb +15 -0
- data/db/seeds.rb +19 -0
- data/lib/collavre/aws_credentials.rb +75 -0
- data/lib/collavre/engine.rb +51 -0
- data/lib/collavre/integration_settings/key_definition.rb +29 -0
- data/lib/collavre/integration_settings/registry.rb +55 -0
- data/lib/collavre/integration_settings/resolver.rb +71 -0
- data/lib/collavre/integration_settings.rb +46 -0
- data/lib/collavre/ses_settings_interceptor.rb +72 -0
- data/lib/collavre/version.rb +1 -1
- data/lib/collavre.rb +3 -0
- metadata +52 -1
|
@@ -14,25 +14,38 @@ module Collavre
|
|
|
14
14
|
# Falls back to lightweight regex conversion for short inline fragments.
|
|
15
15
|
def markdown_to_html(text, image_refs = {})
|
|
16
16
|
return "" if text.nil?
|
|
17
|
-
input = text.dup
|
|
18
17
|
|
|
19
|
-
#
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
# Protect fenced code blocks and inline code spans from the regex
|
|
19
|
+
# rewrites below — a Markdown sample like ```` ```md\n\n``` ````
|
|
20
|
+
# must not silently upload its data URI as an Active Storage blob.
|
|
21
|
+
input = with_code_protected(text.dup) do |source|
|
|
22
|
+
# Collect reference-style data-URI images. CommonMark allows both the
|
|
23
|
+
# angle-bracket form `[alt]: <data:...>` and the bare form
|
|
24
|
+
# `[alt]: data:image/...`, optionally followed by a title.
|
|
25
|
+
source.gsub!(/^\s*\[([^\]]+)\]:\s*(?:<\s*(data:image\/[^>]+?)\s*>|(data:image\/\S+))\s*(?:"[^"]*"|'[^']*'|\([^)]*\))?\s*$/) do
|
|
26
|
+
image_refs[$1] = ($2 || $3).strip
|
|
27
|
+
""
|
|
28
|
+
end
|
|
24
29
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
# Convert data-URI images to Active Storage before rendering
|
|
31
|
+
source.gsub!(/(?<!\\)!\[([^\]]*)\]\[([^\]]+)\]/) do
|
|
32
|
+
if (data_url = image_refs[$2])
|
|
33
|
+
data_image_to_attachment(data_url, $1)
|
|
34
|
+
else
|
|
35
|
+
"![#{$1}][#{$2}]"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Inline data-URI images. base64 contains no whitespace or `)`, so bound
|
|
40
|
+
# the URL on those and capture an optional CommonMark title separately;
|
|
41
|
+
# otherwise `data:...;base64,XYZ "caption"` would be slurped as one URL
|
|
42
|
+
# and fail the strict parser in `data_image_to_attachment`. Titles may be
|
|
43
|
+
# double-quoted, single-quoted, or parenthesized per CommonMark.
|
|
44
|
+
source.gsub!(/(?<!\\)!\[([^\]]*)\]\(\s*(data:image\/[^\s)]+)(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\)/) do
|
|
45
|
+
data_image_to_attachment($2, $1)
|
|
31
46
|
end
|
|
32
|
-
end
|
|
33
47
|
|
|
34
|
-
|
|
35
|
-
data_image_to_attachment($2, $1)
|
|
48
|
+
source
|
|
36
49
|
end
|
|
37
50
|
|
|
38
51
|
# Render with commonmarker (GFM extensions: table, strikethrough, autolink, tasklist, tagfilter)
|
|
@@ -194,8 +207,110 @@ module Collavre
|
|
|
194
207
|
end
|
|
195
208
|
end
|
|
196
209
|
|
|
210
|
+
# Rewrite data-URI image references in Markdown source to point at
|
|
211
|
+
# newly-created Active Storage blobs. Returns rewritten Markdown.
|
|
212
|
+
#
|
|
213
|
+
# Used by the inline Markdown editor to persist blob URLs in the stored
|
|
214
|
+
# `markdown_source`, so subsequent text edits around the image do not
|
|
215
|
+
# re-import the same data URI into a fresh blob on every autosave.
|
|
216
|
+
def rewrite_data_uri_images(text)
|
|
217
|
+
return text if text.nil?
|
|
218
|
+
image_refs = {}
|
|
219
|
+
|
|
220
|
+
# Protect fenced code blocks and inline code spans before rewriting —
|
|
221
|
+
# a Markdown code sample like ```` ```md\n\n``` ```` must
|
|
222
|
+
# not be silently uploaded as a blob and have its source rewritten,
|
|
223
|
+
# which would corrupt the user's code snippet on every autosave.
|
|
224
|
+
with_code_protected(text.dup) do |result|
|
|
225
|
+
# Collect reference-style data-URI image definitions. CommonMark accepts
|
|
226
|
+
# both the angle-bracket form `[alt]: <data:...>` and the bare form
|
|
227
|
+
# `[alt]: data:image/...`, optionally followed by a title. Rewrite each
|
|
228
|
+
# definition to point at a freshly-uploaded blob, normalizing to the
|
|
229
|
+
# bare form (titles are dropped since blob paths cannot collide with
|
|
230
|
+
# surrounding text).
|
|
231
|
+
result.gsub!(/^(\s*\[)([^\]]+)(\]:\s*)(?:<\s*(data:image\/[^>]+?)\s*>|(data:image\/\S+))(\s*(?:"[^"]*"|'[^']*'|\([^)]*\))?\s*)$/) do
|
|
232
|
+
lead, label, mid, angle_url, bare_url, tail = $1, $2, $3, $4, $5, $6
|
|
233
|
+
data_url = (angle_url || bare_url).strip
|
|
234
|
+
blob_path = data_uri_to_blob_path(data_url)
|
|
235
|
+
image_refs[label] = blob_path
|
|
236
|
+
"#{lead}#{label}#{mid}#{blob_path}#{tail}"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Inline data-URI images:  or 
|
|
240
|
+
# →  / . Parse the title
|
|
241
|
+
# separately so it doesn't get captured into the data URL and break
|
|
242
|
+
# strict matching in `data_uri_to_blob_path`. Titles may be
|
|
243
|
+
# double-quoted, single-quoted, or parenthesized per CommonMark.
|
|
244
|
+
result.gsub!(/(?<!\\)!\[([^\]]*)\]\(\s*(data:image\/[^\s)]+)(\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\)/) do
|
|
245
|
+
alt, data_url, title = $1, $2, $3
|
|
246
|
+
"}#{title})"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
result
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Convert a data-URI to a freshly-uploaded Active Storage blob path.
|
|
254
|
+
# Returns the original URL unchanged on parse failure.
|
|
255
|
+
def data_uri_to_blob_path(data_url)
|
|
256
|
+
return data_url unless data_url =~ %r{\Adata:(image/[\w.+-]+);base64,(.+)\z}
|
|
257
|
+
|
|
258
|
+
content_type = Regexp.last_match(1)
|
|
259
|
+
data = Base64.decode64(Regexp.last_match(2))
|
|
260
|
+
ext = Mime::Type.lookup(content_type).symbol.to_s
|
|
261
|
+
filename = "import-#{SecureRandom.hex}.#{ext}"
|
|
262
|
+
blob = ActiveStorage::Blob.create_and_upload!(io: StringIO.new(data), filename: filename, content_type: content_type)
|
|
263
|
+
Rails.application.routes.url_helpers.rails_blob_url(blob, only_path: true)
|
|
264
|
+
end
|
|
265
|
+
|
|
197
266
|
private
|
|
198
267
|
|
|
268
|
+
# Run a regex-rewriting block on `text` with fenced code blocks,
|
|
269
|
+
# indented code blocks, and inline code spans swapped for unique tokens,
|
|
270
|
+
# then restore the original segments in the block's return value.
|
|
271
|
+
# Prevents data-URI rewrites from touching code samples that happen to
|
|
272
|
+
# contain image syntax.
|
|
273
|
+
def with_code_protected(text)
|
|
274
|
+
segments = {}
|
|
275
|
+
index = 0
|
|
276
|
+
protected_text = text
|
|
277
|
+
|
|
278
|
+
# Fenced code blocks (``` or ~~~, indented up to 3 spaces per CommonMark).
|
|
279
|
+
# The closing fence is optional: per CommonMark, an unclosed fence runs to
|
|
280
|
+
# end-of-document, so we still need to protect its contents from rewrites.
|
|
281
|
+
protected_text.gsub!(/^([ \t]{0,3})(`{3,}|~{3,})[^\n]*(?:\n(?:[\s\S]*?\n\1\2[ \t]*(?=\n|\z)|[\s\S]*\z)|\z)/) do |match|
|
|
282
|
+
token = "\x00MDPROTECT#{index}\x00"
|
|
283
|
+
segments[token] = match
|
|
284
|
+
index += 1
|
|
285
|
+
token
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Indented code blocks: contiguous lines each starting with 4+ spaces
|
|
289
|
+
# or a tab, preceded by start-of-document or a blank line (CommonRule
|
|
290
|
+
# requirement so we don't mistake list-item continuations for code).
|
|
291
|
+
protected_text.gsub!(/(\A|\n\n+)((?:(?:[ ]{4,}|\t)[^\n]*(?:\n|\z))+)/) do
|
|
292
|
+
prefix = Regexp.last_match(1)
|
|
293
|
+
block = Regexp.last_match(2)
|
|
294
|
+
token = "\x00MDPROTECT#{index}\x00"
|
|
295
|
+
segments[token] = block
|
|
296
|
+
index += 1
|
|
297
|
+
"#{prefix}#{token}"
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Inline single-backtick code spans. Multi-backtick spans are rare in
|
|
301
|
+
# practice; the protection above already covers fenced samples.
|
|
302
|
+
protected_text.gsub!(/`[^`\n]+?`/) do |match|
|
|
303
|
+
token = "\x00MDPROTECT#{index}\x00"
|
|
304
|
+
segments[token] = match
|
|
305
|
+
index += 1
|
|
306
|
+
token
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
rewritten = yield(protected_text)
|
|
310
|
+
segments.each { |token, original| rewritten.sub!(token, original) }
|
|
311
|
+
rewritten
|
|
312
|
+
end
|
|
313
|
+
|
|
199
314
|
def escape_table_cell(text)
|
|
200
315
|
text.to_s.gsub(/(?<!\\)\|/, '\\|')
|
|
201
316
|
end
|
|
@@ -6,9 +6,14 @@ module Collavre
|
|
|
6
6
|
def self.import(content, parent:, user:, create_root: false)
|
|
7
7
|
lines = content.to_s.lines
|
|
8
8
|
image_refs = {}
|
|
9
|
+
# CommonMark allows both the angle-bracket form `[alt]: <data:...>` and
|
|
10
|
+
# the bare form `[alt]: data:image/...`, optionally followed by a title
|
|
11
|
+
# (`"..."`, `'...'`, or `(...)`). Match both so reference-style data-URI
|
|
12
|
+
# images survive the import path and resolve to attachments downstream
|
|
13
|
+
# (mirrors MarkdownConverter#markdown_to_html).
|
|
9
14
|
lines.reject! do |ln|
|
|
10
|
-
if ln =~ /^\s*\[([^\]]+)\]:\s
|
|
11
|
-
image_refs[$1] = $2.strip
|
|
15
|
+
if ln =~ /^\s*\[([^\]]+)\]:\s*(?:<\s*(data:image\/[^>]+?)\s*>|(data:image\/\S+))\s*(?:"[^"]*"|'[^']*'|\([^)]*\))?\s*$/
|
|
16
|
+
image_refs[$1] = ($2 || $3).strip
|
|
12
17
|
true
|
|
13
18
|
else
|
|
14
19
|
false
|
|
@@ -88,6 +88,8 @@ module Collavre
|
|
|
88
88
|
|
|
89
89
|
# Convenience methods
|
|
90
90
|
def arbitration_strategy
|
|
91
|
+
return "primary_first" if topic_primary_agent_id.present?
|
|
92
|
+
|
|
91
93
|
arbitration_config["strategy"]
|
|
92
94
|
end
|
|
93
95
|
|
|
@@ -96,7 +98,7 @@ module Collavre
|
|
|
96
98
|
end
|
|
97
99
|
|
|
98
100
|
def primary_agent_id
|
|
99
|
-
arbitration_config["primary_agent_id"]
|
|
101
|
+
topic_primary_agent_id || arbitration_config["primary_agent_id"]
|
|
100
102
|
end
|
|
101
103
|
|
|
102
104
|
# Bid strategy specific
|
|
@@ -160,6 +162,14 @@ module Collavre
|
|
|
160
162
|
|
|
161
163
|
config
|
|
162
164
|
end
|
|
165
|
+
|
|
166
|
+
# Read primary_agent_id directly from the topics table
|
|
167
|
+
def topic_primary_agent_id
|
|
168
|
+
topic_id = @context.dig("topic", "id")
|
|
169
|
+
return nil unless topic_id
|
|
170
|
+
|
|
171
|
+
Topic.where(id: topic_id).pick(:primary_agent_id)
|
|
172
|
+
end
|
|
163
173
|
end
|
|
164
174
|
end
|
|
165
175
|
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
require "sorbet-runtime"
|
|
3
|
+
require "rails_mcp_engine"
|
|
4
|
+
module Tools
|
|
5
|
+
class CreativeAttachFilesService
|
|
6
|
+
extend T::Sig
|
|
7
|
+
extend ToolMeta
|
|
8
|
+
include Collavre::PublicAssetsHelper
|
|
9
|
+
|
|
10
|
+
tool_name "creative_attach_files_service"
|
|
11
|
+
tool_description "Attach one or more local files (images, video, documents) to a Creative. Files are uploaded to ActiveStorage (S3) and served publicly via CloudFront-cached /public-assets URLs.\n\nUse this to:\n- Upload landing page assets (hero images, demo videos)\n- Attach reference documents to a task\n\nFile paths must live under the configured upload root (MCP_UPLOAD_ROOT, default tmp/mcp_uploads under Rails.root). Paths outside that root are rejected.\n\nRequires :write permission on the target Creative."
|
|
12
|
+
|
|
13
|
+
tool_param :creative_id, description: "ID of the Creative to attach files to.", required: true
|
|
14
|
+
tool_param :file_paths, description: "Absolute local file paths to upload. Each path must resolve to a regular file under the upload root (MCP_UPLOAD_ROOT). If any path is missing or escapes the upload root, the entire call fails atomically.", required: true
|
|
15
|
+
|
|
16
|
+
sig { params(creative_id: Integer, file_paths: T::Array[String]).returns(T::Hash[Symbol, T.untyped]) }
|
|
17
|
+
def call(creative_id:, file_paths:)
|
|
18
|
+
raise "Current.user is required" unless Current.user
|
|
19
|
+
|
|
20
|
+
creative = Creative.find_by(id: creative_id)
|
|
21
|
+
return { error: "Creative not found", id: creative_id } unless creative
|
|
22
|
+
|
|
23
|
+
unless creative.has_permission?(Current.user, :write)
|
|
24
|
+
return { error: "No write permission on Creative", id: creative_id }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
root = self.class.upload_root
|
|
28
|
+
resolved = []
|
|
29
|
+
missing = []
|
|
30
|
+
outside = []
|
|
31
|
+
file_paths.each do |raw|
|
|
32
|
+
begin
|
|
33
|
+
real = File.realpath(raw)
|
|
34
|
+
rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES
|
|
35
|
+
missing << raw
|
|
36
|
+
next
|
|
37
|
+
end
|
|
38
|
+
unless File.file?(real)
|
|
39
|
+
missing << raw
|
|
40
|
+
next
|
|
41
|
+
end
|
|
42
|
+
if path_under?(real, root)
|
|
43
|
+
resolved << real
|
|
44
|
+
else
|
|
45
|
+
outside << raw
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
return { error: "Missing files", missing: missing } if missing.any?
|
|
50
|
+
return { error: "Files outside upload root", upload_root: root.to_s, outside: outside } if outside.any?
|
|
51
|
+
|
|
52
|
+
attached = []
|
|
53
|
+
ActiveRecord::Base.transaction do
|
|
54
|
+
resolved.each do |path|
|
|
55
|
+
File.open(path, "rb") do |io|
|
|
56
|
+
creative.files.attach(
|
|
57
|
+
io: io,
|
|
58
|
+
filename: File.basename(path),
|
|
59
|
+
content_type: Marcel::MimeType.for(Pathname.new(path)) || "application/octet-stream"
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
creative.save!
|
|
64
|
+
attached = creative.files.last(resolved.length)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
{
|
|
68
|
+
success: true,
|
|
69
|
+
creative_id: creative.id,
|
|
70
|
+
attachments: attached.map { |a|
|
|
71
|
+
{
|
|
72
|
+
signed_id: a.blob.signed_id,
|
|
73
|
+
filename: a.filename.to_s,
|
|
74
|
+
content_type: a.content_type,
|
|
75
|
+
byte_size: a.byte_size,
|
|
76
|
+
url: public_asset_url(a.blob)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.upload_root
|
|
83
|
+
raw = Collavre::IntegrationSettings.fetch(:mcp_upload_root, default: Rails.root.join("tmp/mcp_uploads").to_s)
|
|
84
|
+
Pathname.new(File.expand_path(raw)).tap do |p|
|
|
85
|
+
FileUtils.mkdir_p(p) unless p.exist?
|
|
86
|
+
end.realpath
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def path_under?(path, root)
|
|
92
|
+
Pathname.new(path).ascend.any? { |p| p == root }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
require "sorbet-runtime"
|
|
3
|
+
require "rails_mcp_engine"
|
|
4
|
+
module Tools
|
|
5
|
+
class CreativeListAttachmentsService
|
|
6
|
+
extend T::Sig
|
|
7
|
+
extend ToolMeta
|
|
8
|
+
include Collavre::PublicAssetsHelper
|
|
9
|
+
|
|
10
|
+
tool_name "creative_list_attachments_service"
|
|
11
|
+
tool_description "List all attachments on a Creative with public URLs, filenames, content types, and sizes. Requires :read permission."
|
|
12
|
+
|
|
13
|
+
tool_param :creative_id, description: "ID of the Creative.", required: true
|
|
14
|
+
|
|
15
|
+
sig { params(creative_id: Integer).returns(T::Hash[Symbol, T.untyped]) }
|
|
16
|
+
def call(creative_id:)
|
|
17
|
+
raise "Current.user is required" unless Current.user
|
|
18
|
+
|
|
19
|
+
creative = Creative.find_by(id: creative_id)
|
|
20
|
+
return { error: "Creative not found", id: creative_id } unless creative
|
|
21
|
+
|
|
22
|
+
unless creative.has_permission?(Current.user, :read)
|
|
23
|
+
return { error: "No read permission on Creative", id: creative_id }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
{
|
|
27
|
+
success: true,
|
|
28
|
+
creative_id: creative.id,
|
|
29
|
+
attachments: creative.files.with_all_variant_records.map { |a|
|
|
30
|
+
{
|
|
31
|
+
signed_id: a.blob.signed_id,
|
|
32
|
+
filename: a.filename.to_s,
|
|
33
|
+
content_type: a.content_type,
|
|
34
|
+
byte_size: a.byte_size,
|
|
35
|
+
url: public_asset_url(a.blob)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
require "sorbet-runtime"
|
|
3
|
+
require "rails_mcp_engine"
|
|
4
|
+
module Tools
|
|
5
|
+
class CreativeRemoveAttachmentService
|
|
6
|
+
extend T::Sig
|
|
7
|
+
extend ToolMeta
|
|
8
|
+
|
|
9
|
+
tool_name "creative_remove_attachment_service"
|
|
10
|
+
tool_description "Remove a single attachment from a Creative by its signed_id. Requires :write permission. The underlying blob is purged asynchronously."
|
|
11
|
+
|
|
12
|
+
tool_param :creative_id, description: "ID of the Creative.", required: true
|
|
13
|
+
tool_param :signed_id, description: "signed_id of the blob (from creative_list_attachments_service or creative_attach_files_service response).", required: true
|
|
14
|
+
|
|
15
|
+
sig { params(creative_id: Integer, signed_id: String).returns(T::Hash[Symbol, T.untyped]) }
|
|
16
|
+
def call(creative_id:, signed_id:)
|
|
17
|
+
raise "Current.user is required" unless Current.user
|
|
18
|
+
|
|
19
|
+
creative = Creative.find_by(id: creative_id)
|
|
20
|
+
return { error: "Creative not found", id: creative_id } unless creative
|
|
21
|
+
|
|
22
|
+
unless creative.has_permission?(Current.user, :write)
|
|
23
|
+
return { error: "No write permission on Creative", id: creative_id }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
blob = ActiveStorage::Blob.find_signed(signed_id)
|
|
27
|
+
attachment = blob && creative.files.attachments.find_by(blob_id: blob.id)
|
|
28
|
+
return { error: "Attachment not found on this Creative" } unless attachment
|
|
29
|
+
|
|
30
|
+
attachment.purge_later
|
|
31
|
+
{ success: true, creative_id: creative.id, removed_signed_id: signed_id }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
require "sorbet-runtime"
|
|
3
|
+
require "rails_mcp_engine"
|
|
4
|
+
module Tools
|
|
5
|
+
# Attach a development-preview chip to a topic. AI Agents call this after
|
|
6
|
+
# spinning up `./bin/dev` against a worktree so the preview URL and run
|
|
7
|
+
# state surface as a chip in the typing-indicator row. Idempotent by
|
|
8
|
+
# (topic_id, worktree_id).
|
|
9
|
+
class PreviewAttachService
|
|
10
|
+
extend T::Sig
|
|
11
|
+
extend ToolMeta
|
|
12
|
+
|
|
13
|
+
tool_name "preview_attach"
|
|
14
|
+
tool_description <<~DESC.strip
|
|
15
|
+
Attach a development preview server to a Collavre topic. Renders a
|
|
16
|
+
chip in the topic's typing-indicator row with a clickable link to the
|
|
17
|
+
preview URL and a state badge (running/stopped). Call this after
|
|
18
|
+
starting a preview server (e.g. ./bin/dev on a worktree). Idempotent
|
|
19
|
+
per (topic_id, worktree_id) — calling twice does not duplicate the
|
|
20
|
+
chip or re-announce.
|
|
21
|
+
DESC
|
|
22
|
+
|
|
23
|
+
tool_param :topic_id, description: "The Collavre topic id to attach the preview chip to."
|
|
24
|
+
tool_param :preview_url, description: "Full URL of the preview server, e.g. http://localhost:4001"
|
|
25
|
+
tool_param :worktree_id, description: "Stable identifier for the worktree (or environment) running this preview. Used as the dedup key — re-calling with the same worktree_id reactivates the existing chip instead of creating a duplicate."
|
|
26
|
+
tool_param :label, description: "Optional display label shown on the chip. Defaults to a localized 'Preview' string.", required: false
|
|
27
|
+
|
|
28
|
+
sig do
|
|
29
|
+
params(
|
|
30
|
+
topic_id: Integer,
|
|
31
|
+
preview_url: String,
|
|
32
|
+
worktree_id: String,
|
|
33
|
+
label: T.nilable(String)
|
|
34
|
+
).returns(T::Hash[Symbol, T.untyped])
|
|
35
|
+
end
|
|
36
|
+
def call(topic_id:, preview_url:, worktree_id:, label: nil)
|
|
37
|
+
validate_preview_url!(preview_url)
|
|
38
|
+
|
|
39
|
+
topic = Collavre::Topic.find(topic_id)
|
|
40
|
+
Collavre::Tools::TopicAuthorizer.authorize_write!(topic)
|
|
41
|
+
|
|
42
|
+
refresh_config = ->(c) {
|
|
43
|
+
# Re-attach against the same worktree may carry a fresh URL/label
|
|
44
|
+
# (e.g. port reassignment after a restart). Refresh the persisted
|
|
45
|
+
# config so the chip link goes to the live server, not the dead one
|
|
46
|
+
# — applies both when reactivating a stopped chip and when the chip
|
|
47
|
+
# is still active (:noop), which is the common dev-restart case.
|
|
48
|
+
new_config = c.config.merge(
|
|
49
|
+
"preview_url" => preview_url,
|
|
50
|
+
"preview_state" => "running"
|
|
51
|
+
)
|
|
52
|
+
new_config["label"] = label if label
|
|
53
|
+
c.config = new_config
|
|
54
|
+
# Also refresh the cached chip fields that record_event! seeded on the
|
|
55
|
+
# first attach. The chip partial renders latest_link.presence ||
|
|
56
|
+
# default_link, so without this update a :noop reattach with a new port
|
|
57
|
+
# leaves the chip pointing at the dead URL even though config is fresh.
|
|
58
|
+
# (On :reactivate the subsequent inject_into_topic! would overwrite
|
|
59
|
+
# these via record_event! anyway, so updating here is harmless.)
|
|
60
|
+
c.latest_link = preview_url
|
|
61
|
+
c.latest_label = c.default_label
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
channel, status = Collavre::ChannelAttacher.call(
|
|
65
|
+
channel_class: Collavre::PreviewChannel,
|
|
66
|
+
lookup: -> { lookup_channel(topic, worktree_id) },
|
|
67
|
+
create_attrs: {
|
|
68
|
+
topic_id: topic.id,
|
|
69
|
+
config: {
|
|
70
|
+
"worktree_id" => worktree_id,
|
|
71
|
+
"preview_url" => preview_url,
|
|
72
|
+
"preview_state" => "running",
|
|
73
|
+
"label" => label
|
|
74
|
+
}.compact
|
|
75
|
+
},
|
|
76
|
+
on_reactivate: refresh_config,
|
|
77
|
+
on_noop: refresh_config
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Mirror PR-channel UX: only the first attach (and a true reactivation
|
|
81
|
+
# from stopped/dismissed) announces in the topic. Subsequent idempotent
|
|
82
|
+
# attaches stay silent so frequent restarts do not spam the timeline.
|
|
83
|
+
if status == :created || status == :reactivated
|
|
84
|
+
channel.inject_into_topic!(channel.attached_message)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
{
|
|
88
|
+
ok: true,
|
|
89
|
+
channel_id: channel.id,
|
|
90
|
+
worktree_id: worktree_id,
|
|
91
|
+
preview_url: preview_url,
|
|
92
|
+
status: status
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
ALLOWED_PREVIEW_URL_SCHEMES = %w[http https].freeze
|
|
99
|
+
private_constant :ALLOWED_PREVIEW_URL_SCHEMES
|
|
100
|
+
|
|
101
|
+
# The preview_url is rendered directly as the chip's <a href> via ERB,
|
|
102
|
+
# which escapes quotes but does NOT reject dangerous URL schemes. Without
|
|
103
|
+
# this gate a write-capable MCP caller could persist `javascript:...` or
|
|
104
|
+
# `data:...` and turn the chip into a one-click XSS for any topic viewer.
|
|
105
|
+
sig { params(preview_url: String).void }
|
|
106
|
+
def validate_preview_url!(preview_url)
|
|
107
|
+
uri = URI.parse(preview_url)
|
|
108
|
+
unless ALLOWED_PREVIEW_URL_SCHEMES.include?(uri.scheme&.downcase)
|
|
109
|
+
raise ArgumentError, "preview_url must be http(s); got: #{preview_url.inspect}"
|
|
110
|
+
end
|
|
111
|
+
raise ArgumentError, "preview_url must include a host" if uri.host.to_s.empty?
|
|
112
|
+
rescue URI::InvalidURIError
|
|
113
|
+
raise ArgumentError, "preview_url is not a valid URI: #{preview_url.inspect}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# config is JSON, so worktree_id matching is a Ruby-side scan over the
|
|
117
|
+
# topic's preview channels. With only a handful of previews per topic in
|
|
118
|
+
# practice this is fine and avoids JSON operator divergence between
|
|
119
|
+
# Postgres (config->>'worktree_id') and SQLite (json_extract).
|
|
120
|
+
sig { params(topic: Collavre::Topic, worktree_id: String).returns(T.nilable(Collavre::PreviewChannel)) }
|
|
121
|
+
def lookup_channel(topic, worktree_id)
|
|
122
|
+
Collavre::PreviewChannel.where(topic_id: topic.id).find do |c|
|
|
123
|
+
c.worktree_id.to_s == worktree_id.to_s
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
require "sorbet-runtime"
|
|
3
|
+
require "rails_mcp_engine"
|
|
4
|
+
module Tools
|
|
5
|
+
# Mark a previously-attached preview as stopped. AI Agents call this
|
|
6
|
+
# immediately before killing the preview server (per the worktree cleanup
|
|
7
|
+
# workflow) so the chip flips to the "stopped" badge with reduced opacity.
|
|
8
|
+
# The chip stays visible until the user dismisses it with the X button —
|
|
9
|
+
# mirrors the PR-channel post-close UX where the closed badge persists for
|
|
10
|
+
# context.
|
|
11
|
+
class PreviewDetachService
|
|
12
|
+
extend T::Sig
|
|
13
|
+
extend ToolMeta
|
|
14
|
+
|
|
15
|
+
tool_name "preview_detach"
|
|
16
|
+
tool_description <<~DESC.strip
|
|
17
|
+
Mark a development preview as stopped. The chip stays visible with a
|
|
18
|
+
stopped badge until the user dismisses it. Idempotent — calling on an
|
|
19
|
+
already-stopped or missing channel returns ok with status :noop.
|
|
20
|
+
DESC
|
|
21
|
+
|
|
22
|
+
tool_param :topic_id, description: "The Collavre topic id the preview was attached to."
|
|
23
|
+
tool_param :worktree_id, description: "The worktree_id used when the preview was attached."
|
|
24
|
+
|
|
25
|
+
sig do
|
|
26
|
+
params(
|
|
27
|
+
topic_id: Integer,
|
|
28
|
+
worktree_id: String
|
|
29
|
+
).returns(T::Hash[Symbol, T.untyped])
|
|
30
|
+
end
|
|
31
|
+
def call(topic_id:, worktree_id:)
|
|
32
|
+
topic = Collavre::Topic.find(topic_id)
|
|
33
|
+
Collavre::Tools::TopicAuthorizer.authorize_write!(topic)
|
|
34
|
+
|
|
35
|
+
channel = lookup_channel(topic, worktree_id)
|
|
36
|
+
return { ok: true, status: :noop, worktree_id: worktree_id } if channel.nil?
|
|
37
|
+
|
|
38
|
+
status =
|
|
39
|
+
if channel.preview_state == "stopped" && channel.detached?
|
|
40
|
+
:noop
|
|
41
|
+
else
|
|
42
|
+
channel.preview_state = "stopped"
|
|
43
|
+
channel.state = :detached unless channel.detached?
|
|
44
|
+
channel.save!
|
|
45
|
+
:stopped
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
{ ok: true, channel_id: channel.id, worktree_id: worktree_id, status: status }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
sig { params(topic: Collavre::Topic, worktree_id: String).returns(T.nilable(Collavre::PreviewChannel)) }
|
|
54
|
+
def lookup_channel(topic, worktree_id)
|
|
55
|
+
Collavre::PreviewChannel.where(topic_id: topic.id).find do |c|
|
|
56
|
+
c.worktree_id.to_s == worktree_id.to_s
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module Tools
|
|
5
|
+
# Topic write authorization for MCP tool services. Mirrors
|
|
6
|
+
# CreativePermissionGuard#require_creative_write! but is callable outside a
|
|
7
|
+
# controller request. Attaching a channel injects external messages into a
|
|
8
|
+
# topic, so it is a write-equivalent mutation — restrict to users with
|
|
9
|
+
# write permission on the topic's effective_origin creative.
|
|
10
|
+
module TopicAuthorizer
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def authorize_write!(topic, user: Collavre::Current.user)
|
|
14
|
+
creative = topic.creative&.effective_origin
|
|
15
|
+
raise ArgumentError, "Topic has no creative" unless creative
|
|
16
|
+
return if creative.user == user
|
|
17
|
+
return if user && creative.has_permission?(user, :write)
|
|
18
|
+
|
|
19
|
+
raise Collavre::Tools::PermissionDeniedError,
|
|
20
|
+
"No write permission on topic #{topic.id}"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|