Adding Website Card Embeds to my Bluesky Posts

Bluesky’s AT Protocol and client app has some interesting choices. One of them is that links inside posts don’t get unfurled into previews/cards. So when I started syndicating links to my posts to Bluesky, they didn’t really look like much: just truncated blue text. Because every client has to provide the website preview themselves.

Their API docs page has some reasoning about this:

Embedding the card content in the record ensures that it appears consistently to everyone and reduces waves of automated traffic being sent to the referenced website, but it does require some extra work by the client.

I can understand that, but it comes with some interesting downsides:

  • The client making the post can put whatever they want in that “preview” - like something totally unrelated in a phishing attempt.
  • Bluesky gets to host a ton of assets to power this, and it’s all just duplicated from the source of truth.

It’s kind of annoying to not have links “just work” on Bluesky, but I guess 99.999% of the people there just won’t notice.

bluesky post preview

Anyway, this morning I added some additional logic to my Ruby script for posting to Bluesky, and now my posts have website preview cards/embeds. The logic is pretty simple:

  • Create an embed object on the post
  • Use the title and description from the post
  • If the post has an image:
    • Download it locally if it’s not already local
    • Determine its mime type
    • Upload it to Bluesky’s blob endpoint to get a blob ID
    • Add that as thumb on the embed

Code

# [omitted... see previous post for full code]
require 'open-uri'

module POSSE
  class JekyllFilter
    include Jekyll::Filters
    attr_accessor :site, :context

    def initialize(opts = {})
      @site = Jekyll::Site.new(Jekyll.configuration(opts))
      @context = Liquid::Context.new(@site.site_payload, {}, site: @site)
    end
  end

  class Bluesky
    class Error < StandardError; end

    def initialize
      # [omitted... see previous post for full code]
      @jekyll_filter = JekyllFilter.new
      @content_template = Liquid::Template.parse('')
    end

    # [omitted... see previous post for full code]

    private

    def embed(post)
      card = {
        "uri": url(post),
        "title": post.data['title'],
        "description": excerpt(post)
      }
      card['thumb'] = image_blob(post) if post.data['image']
      {
        "$type": 'app.bsky.embed.external',
        "external": card
      }
    end

    def image_blob(post)
      image_path = post.data['image']
      if image_path.start_with?('http')
        image_path = URI.parse(image_path)
        image_path = File.join(Dir.tmpdir, File.basename(image_path.path))
        File.open(image_path, 'wb') do |file|
          file.write(URI.open(post.data['image']).read)
        end
      else
        image_path = File.join('.', image_path)
      end
      filetype = `file --mime-type -b #{image_path}`.strip
      uri = URI.parse('https://bsky.social/xrpc/com.atproto.repo.uploadBlob')
      request = Net::HTTP::Post.new(uri)
      request.content_type = filetype
      request['Authorization'] = "Bearer #{@api_key}"
      request.body = File.binread(image_path)
      req_options = {
        use_ssl: uri.scheme == 'https'
      }
      res = Net::HTTP.start(uri.hostname, uri.port, req_options) do |http|
        http.request(request)
      end
      raise Error, 'Image blob post request failed' unless res.is_a?(Net::HTTPSuccess)

      JSON.parse(res.body)['blob']
    end

    def excerpt(post)
      post.data['description'] ||
        @content_template.render('x' => @jekyll_filter.markdownify(post.content))
    end
  end
end

Keyboard Shortcuts

Key Action
o Source
e Edit
i Insight
r Random
s or / Search
www.joshbeckman.org/blog/bluesky-website-embeds-ruby