In the previous post, we learned how to add mentions to your post when posting to Bluesky.

The last part of this series will learn you how to add website cards to your posts.

You'll see that it resembles the way you can add tags and mentions, but with an extra step.

Step 1: Get the preview image for the website

As we want our website card to have a nice image, we need to fetch it from the URL. To do so, we are going to use the Open Graph image.

Don't worry, you don't need to do this manually. You can use the opengraph_parser module for this. We can install it by adding the following dependency to your mix.exs file:

{:opengraph_parser, "~> 0.6.0"}

Once installed, you can use it fetch the Open Graph image using the website's URL. Just in case there is no image, I'm adding a fallback to the favicon of the website by using the Favicon Extractor web service.

# First, fetch the body of the URL, limiting the redirects to avoid possible endless loops
body = Req.get!(url, max_redirects: 1).body

# Then use the OpenGraph module to get the OpenGraph info
og_info = OpenGraph.parse(body)

# The fallback URL in case there is no image
hostname = URI.parse(url).host
favicon_url = "https://www.faviconextractor.com/favicon/#{hostname}?larger=true"

# Extract the image
image_url = og_info.image || favicon_url

Step 2: Uploading the image as a blob

The next step is that you need to upload the image as a blob so that you can later on use it in the website card.

We'll first get the actual image from the URL so that we have the raw image data. We also determine the image content type as we need it to create the blob.

The blob can then be created by using the com.atproto.repo.uploadBlob function.

# Fetch the image from the URL
image = Req.get!(image_url)

# Get the content type of the image
image_content_type = image |> Req.Response.get_header("content-type")

# Create the actual blob image
blob =
  Req.post!(
    "https://bsky.social/xrpc/com.atproto.repo.uploadBlob",
    headers: %{
      "Content-Type" => image_content_type,
      "Accept" => "application/json"
    },
    auth: {:bearer, accessJwt},
    body: image.body
  ).body["blob"]

The blob value now contains a structure like this:

%{
  "$type" => "blob",
  "mimeType" => "image/jpeg",
  "ref" => %{"$link" => "bafkreih4ittooqpzhubsiwchajmxjbo4uyskqyqoeojrmiixxgofcz3yay"},
  "size" => 54499
}

Step 3: Creating the post record

Instead of facets (which you can still add if you want), we now add an embed record to the post. This record contains the information of the website card to embed and looks like:

# Create the current timestamp in ISO format with a trailing Z
created_at = DateTime.utc_now() |> DateTime.to_iso8601()

# The text to post
text = "Hello from Yellow Duck with a website card"

# Create the post record
record =
  %{
    text: text,
    createdAt: created_at,
    "$type": "app.bsky.feed.post",
    langs: ["en-US"],
    embed: %{
      "$type": "app.bsky.embed.external", # This type indicates a website card
      external: %{
        uri: url, # The URL to the website
        title: title, # The title to the website
        description: description, # The description of the website
        thumb: blob # The resulting structure from step 2
      }
    }
  }

You can also use the Open Graph info to retrieve the title and description of the website.

Step 4: Post to Bluesky

We can now use the same code from the previous posts to publish the record:

%{"commit" => %{"rev" => rev}} =
  Req.post!(
    "https://bsky.social/xrpc/com.atproto.repo.createRecord",
    auth: {:bearer, token},
    json: %{
      repo: did, # The did value from step 1
      collection: "app.bsky.feed.post", # We want to post to our timeline
      record: record # The record we want to post to our timeline
    }
  ).body

If all goes well, the website card should show up in the post.

Conclusion

This ends the series on posting to Bluesky from Elixir.

As you can see, once you know how all the bits and pieces fit together, it's not that hard to automate.

Since this doesn't require too much code, I'm always included to just write a little module myself to handle this rather than relying on an external library which I don't have any control over.