A Bluesky AT Proto API Example in Ruby

Published: November 18, 2024
Bluesky has blown in a bit recently as an X alternative. It provides a free API to post to an account's timeline.

Bluesky is a client for AT Proto. It's API isn't the most obvious to handle and documentation is still in its infancy.

The username is the profile of the account (ie "yourname.bsky.social" or a verified domain). This is the same username you use to sign in to the web or mobile app.

You can generate an app-specific password here.

Below is a (I hope) well-commented and self-contained Ruby class that handles taking a string and posting (skeeting?) it into a Bluesky timeline.

class BlueskyService
  BASE_URL = "https://bsky.social/xrpc"
  TOKEN_CACHE_KEY = :bluesky_token_data

  def initialize
    # This assumes you're using Rails and storing the user identifier and app password
    # in Rails creds. It also assumes the token information (token, renewal token, expiration data, etc.)
    # for a single user in the Rails cache store.
    # Adjust as needed (ie, using environmental variables, taking a user record which holds the
    # credentidals, etc.)
    @username = Rails.application.credentials.dig(:bluesky, :username)
    @password = Rails.application.credentials.dig(:bluesky, :password)
    token_data = Rails.cache.read(TOKEN_CACHE_KEY)
    process_tokens(token_data) if token_data.present?
  end

  # Posts a new message (skeet) to the account and return the direct URL.
  def skeet(message)
    # Generate, refresh, or use an active token.
    verify_tokens

    # URLs and tags are not automatically parsed. Instead we have to manually
    # parse and set facets for each.
    # See: https://docs.bsky.app/docs/advanced-guides/post-richtext
    facets = link_facets(message)
    facets += tag_facets(message)

    body = {
      repo: @user_did, collection: "app.bsky.feed.post",
      record: { text: message, createdAt: Time.now.iso8601, langs: ["en"], facets: facets }
    }
    response_body = post_request("#{BASE_URL}/com.atproto.repo.createRecord", body: body)

    # This is the full atproto URI
    # Ex: "at://did:plc:axbcdefg12345/app.bsky.feed.post/abcdefg12345"
    response_body["uri"]
  end

  # Given a atproto URI of a skeet, parse out the identifying information and remove it
  # from the account's timeline.
  def unskeet(skeet_uri)
    # Generate, refresh, or use an active token.
    verify_tokens

    did, nsid, record_key = skeet_uri.delete_prefix("at://").split("/")
    body = { repo: did, collection: nsid, rkey: record_key }
    post_request("#{BASE_URL}/com.atproto.repo.deleteRecord", body: body)
  end

  private

  def link_facets(message)
    [].tap do |facets|
      matches = []
      message.scan(URI::RFC2396_PARSER.make_regexp(["http", "https"])) { matches << Regexp.last_match }
      matches.each do |match|
        start, stop = match.byteoffset(0)
        facets << {
          "index" => { "byteStart" => start, "byteEnd" => stop },
          "features" => [{ "uri" => match[0], "$type" => "app.bsky.richtext.facet#link" }]
        }
      end
    end
  end

  def tag_facets(message)
    [].tap do |facets|
      matches = []
      message.scan(/(^|[^\w])(#[\w\-]+)/) { matches << Regexp.last_match }
      matches.each do |match|
        start, stop = match.byteoffset(2)
        facets << {
          "index" => { "byteStart" => start, "byteEnd" => stop },
          "features" => [{ "tag" => match[2].delete_prefix("#"), "$type" => "app.bsky.richtext.facet#tag" }]
        }
      end
    end
  end

  # Makes a POST request to the API.
  def post_request(url, body: {}, auth_token: true, content_type: "application/json")
    uri = URI.parse(url)
    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = (uri.scheme == "https")
    http.open_timeout = 4
    http.read_timeout = 4
    http.write_timeout = 4
    request = Net::HTTP::Post.new(uri.request_uri)
    request["content-type"] = content_type

    # This allows the authorization token to:
    #   - Be sent using the currently stored token (true).
    #   - Not send when providing the username/password to generate the token (false).
    #   - Use a different token - like the refresh token (string).
    if auth_token
      token = auth_token.is_a?(String) ? auth_token : @token
      request["Authorization"] = "Bearer #{token}"
    end
    request.body = body.is_a?(Hash) ? body.to_json : body if body.present?
    response = http.request(request)
    raise "#{response.code} response - #{response.body}" unless response.code.to_s.start_with?("2")

    response.content_type == "application/json" ? JSON.parse(response.body) : response.body
  end

  # Generate tokens given an account identifier and app password.
  def generate_tokens
    body = { identifier: @username, password: @password }
    response_body = post_request("#{BASE_URL}/com.atproto.server.createSession", body: body, auth_token: false)

    process_tokens(response_body)
    store_token_data(response_body)
  end

  # Regenerates expired tokens with the refresh token.
  def perform_token_refresh
    response_body = post_request("#{BASE_URL}/com.atproto.server.refreshSession", auth_token: @renewal_token)

    process_tokens(response_body)
    store_token_data(response_body)
  end

  # Makes sure a token is set and the token has not expired.
  # If this is the first request, we'll generate the token.
  # If the token expired, we'll refresh it.
  def verify_tokens
    if @token.nil?
      generate_tokens
    elsif @token_expires_at < Time.now.utc + 60
      perform_token_refresh
    end
  end

  # Given the response body of generating or refreshing token, this pulls out
  # and stores the bits of information we care about.
  def process_tokens(response_body)
    @token = response_body["accessJwt"]
    @renewal_token = response_body["refreshJwt"]
    @user_did = response_body["did"]
    @token_expires_at = Time.at(JSON.parse(Base64.decode64(response_body["accessJwt"].split(".")[1]))["exp"]).utc
  end

  # Stores the token info for use later, else we'll have to generate the token
  # for every instance of this class.
  # Assumes the cached info is stored in the Rails cache store.
  def store_token_data(data)
    Rails.cache.write(TOKEN_CACHE_KEY, data)
  end
end