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