Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion engine/app/models/coplan/link_preview.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module CoPlan
LinkPreview = Data.define(
:kind, :external_id, :canonical_url, :title, :description,
:context, :image_url, :cache_key
:context, :image_url, :author_name, :author_avatar_url, :cache_key
)
end
3 changes: 3 additions & 0 deletions engine/app/services/coplan/link_previews.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,11 @@ def for_plan(plan, base_url:)
# a flag (and never the word "Draft" — matches the in-app language).
context: [ plan_state_flag(plan), plan.plan_type&.name, "by #{plan.created_by_user.name}" ].compact.join(" · "),
image_url: https_url(plan.metadata&.dig("image_url")),
author_name: plan.created_by_user.name,
author_avatar_url: https_url(plan.created_by_user.avatar_url),
cache_key: [
"plan", plan.id, plan.updated_at.to_f, plan.summary_generated_at&.to_f,
plan.created_by_user.updated_at.to_f,
plan.current_plan_version&.content_sha256 || plan.current_revision
].compact.join(":")
)
Expand Down
16 changes: 16 additions & 0 deletions integrations/slack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,22 @@ end

Point Slack's Events API request URL at `/integrations/slack/events`. Configure the `links:read` and `links:write` bot scopes, subscribe to `link_shared`, and register the CoPlan domain under **App unfurl domains**. The endpoint must be reachable from Slack over HTTPS.

## Brand the Slack app

Slack takes the name and icon shown above an unfurl from the app profile, not
from the `chat.unfurl` payload. In the app's **Basic Information → Display
Information** settings, use:

- **App name:** `CoPlan`

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coplan

- **Short description:** `Collaborative engineering plans, reviewed by humans and refined by AI.`
- **App icon:** [`assets/coplan-slack-icon.png`](assets/coplan-slack-icon.png)
- **Background color:** `#010D27`

The icon is intentionally simpler than the in-product logo so the document and
conversation mark stays legible in Slack's small app avatar. The dark profile
color matches CoPlan's navigation chrome; unfurls use CoPlan blue, violet for
private plans, and slate for archived plans.

Any proxy must preserve the raw request body plus `X-Slack-Signature` and `X-Slack-Request-Timestamp`. The adapter verifies Slack's signature and timestamp before parsing the event. An edge rule that merely checks for the signature header is not a replacement for this verification.

This initial adapter supports one deployment-scoped Slack installation. Multi-workspace OAuth installation and token storage are outside its scope.
33 changes: 31 additions & 2 deletions integrations/slack/app/services/coplan/slack/renderer.rb
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
module CoPlan
module Slack
class Renderer
BRAND_COLOR = "#136FF5"
PRIVATE_COLOR = "#8C4AF6"
ARCHIVED_COLOR = "#64748B"

def self.call(preview, url: preview.canonical_url)
section = {
type: "section",
text: { type: "mrkdwn", text: "*<#{escape_url(url)}|#{escape(preview.title)}>*#{description(preview)}" }
}
section[:accessory] = { type: "image", image_url: preview.image_url, alt_text: preview.title.to_s.first(2000) } if preview.image_url.present?
{
color: accent_color(preview),
fallback: [ preview.title, preview.description, preview.context ].compact.join(" — ").first(3000),
blocks: [
section,
{ type: "context", elements: [ { type: "mrkdwn", text: escape(preview.context) } ] }
{ type: "context", elements: context_elements(preview) }
],
preview: {
title: { type: "plain_text", text: preview.title.to_s.first(150) }
Expand All @@ -30,7 +35,31 @@ def self.escape_url(value)
def self.description(preview)
preview.description.present? ? "\n#{escape(preview.description)}" : ""
end
private_class_method :escape_url, :description

def self.accent_color(preview)
return ARCHIVED_COLOR if preview.context.to_s.start_with?("Archived")
return PRIVATE_COLOR if preview.context.to_s.start_with?("Private")

BRAND_COLOR
end

def self.decorated_context(preview)
context = escape(preview.context)
context = context.sub("by #{escape(preview.author_name)}", "by *#{escape(preview.author_name)}*") if preview.author_name.present?
return "📦 *Archived*#{context.delete_prefix("Archived")}" if preview.context.to_s.start_with?("Archived")
return "🔒 *Private*#{context.delete_prefix("Private")}" if preview.context.to_s.start_with?("Private")

"📄 #{context}"
end

def self.context_elements(preview)
elements = []
if preview.author_avatar_url.present?
elements << { type: "image", image_url: preview.author_avatar_url, alt_text: preview.author_name.to_s.first(2000) }
Comment on lines +57 to +58

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard Slack renderer against older engine previews

When coplan-slack is installed with an existing coplan-engine 0.4.x (still allowed by integrations/slack/coplan-slack.gemspec), CoPlan::LinkPreview objects do not expose the new author_avatar_url/author_name accessors, so rendering any unfurl raises NoMethodError before chat.unfurl is called. Either require the engine version that adds these fields or make these accesses backward-compatible.

Useful? React with 👍 / 👎.

end
elements << { type: "mrkdwn", text: decorated_context(preview) }
end
private_class_method :escape_url, :description, :accent_color, :decorated_context, :context_elements
end
end
end
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions spec/services/link_previews_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,16 @@
plan.update!(metadata: { "image_url" => "https://example.test/image.png" })
expect(described_class.for_plan(plan, base_url: base_url).image_url).to eq("https://example.test/image.png")
end

it "includes the author's identity with a safe avatar URL" do
plan.created_by_user.update!(name: "Ada Lovelace", avatar_url: "https://example.test/ada.png")

preview = described_class.for_plan(plan.reload, base_url: base_url)

expect(preview.author_name).to eq("Ada Lovelace")
expect(preview.author_avatar_url).to eq("https://example.test/ada.png")

plan.created_by_user.update!(avatar_url: "http://example.test/ada.png")
expect(described_class.for_plan(plan.reload, base_url: base_url).author_avatar_url).to be_nil
end
end
21 changes: 18 additions & 3 deletions spec/services/slack_renderer_spec.rb
Original file line number Diff line number Diff line change
@@ -1,14 +1,29 @@
require "rails_helper"

RSpec.describe CoPlan::Slack::Renderer do
it "escapes mrkdwn and adds an image accessory and composer title" do
preview = CoPlan::LinkPreview.new(kind: "plan", external_id: "id", canonical_url: "https://example.test/p", title: "A < B & C", description: "> hello", context: "Live & ready", image_url: "https://example.test/i.png", cache_key: "x")
it "renders a branded published-plan unfurl" do
preview = CoPlan::LinkPreview.new(kind: "plan", external_id: "id", canonical_url: "https://example.test/p", title: "A < B & C", description: "> hello", context: "Live & ready · by Ada", image_url: "https://example.test/i.png", author_name: "Ada", author_avatar_url: "https://example.test/ada.png", cache_key: "x")
result = described_class.call(preview, url: "https://example.test/p?thread=123&view=full")
expect(result[:blocks][0][:text][:text]).to include("A &lt; B &amp; C", "&gt; hello")
expect(result[:blocks][1][:elements][0][:text]).to eq("Live &amp; ready")
expect(result[:blocks][1][:elements][0]).to eq(type: "image", image_url: preview.author_avatar_url, alt_text: "Ada")
expect(result[:blocks][1][:elements][1][:text]).to eq("📄 Live &amp; ready · by *Ada*")
expect(result[:blocks][0][:text][:text]).to include("https://example.test/p?thread=123&amp;view=full")
expect(result[:blocks][0][:accessory][:image_url]).to eq(preview.image_url)
expect(result[:color]).to eq("#136FF5")
expect(result[:fallback]).to include(preview.title)
expect(result.dig(:preview, :title, :text)).to eq(preview.title)
end

it "visually distinguishes private and archived plans" do
private_preview = CoPlan::LinkPreview.new(kind: "plan", external_id: "id", canonical_url: "https://example.test/p", title: "Private", description: nil, context: "Private · EDD · by Ada", image_url: nil, author_name: "Ada", author_avatar_url: nil, cache_key: "x")
archived_preview = private_preview.with(title: "Archived", context: "Archived · EDD · by Ada")

private_result = described_class.call(private_preview)
archived_result = described_class.call(archived_preview)

expect(private_result[:color]).to eq("#8C4AF6")
expect(private_result.dig(:blocks, 1, :elements, 0, :text)).to eq("🔒 *Private* · EDD · by *Ada*")
expect(archived_result[:color]).to eq("#64748B")
expect(archived_result.dig(:blocks, 1, :elements, 0, :text)).to eq("📦 *Archived* · EDD · by *Ada*")
end
end
Loading