Cross-posting to Bluesky from Jekyll
Just like how I set up cross-posting to Mastodon for this site (to ensure that I am publishing on my own site and syndicating elsewhere: POSSE), I have now set up cross-posting from this site to my Bluesky account.
Workflow
It’s basically the same sequence of steps, just dancing with the AT protocol instead of ActivityPub:
Then a scheduled/cron workflow/job in GitHub Actions (which hosts the repository for the code/contents of this site) runs twice a day. I use GitHub actions because they run this code for free and provide nice ways to read/manipulate the code in the repository.
The workflow is pretty simple:
- Execute the
utilities/posse_bluesky
script - If there are any code/content changes, commit them and push them up to the repository.
The script, written in Ruby, calls the Bluesky API to resolve my handle and get a temporary API token. It then looks through my posts to see any that have been marked for syndication to Bluesky via Jekyll post frontmatter of bluesky_status_url: false
. For each of those posts, it:
- constructs a message (with a link to the canonical post and the title)
- Identifies and adds markup for the facets present
- Calls the Bluesky API to create a status update with that message
- Updates the post frontmatter with a link to the resulting post on Bluesky
This way, the site can show a link to the Bluesky post and it’s all automated! About 5 hours after I post this, there will be a link at the bottom of the page to the Bluesky-syndicated link for this post.
Code
require 'jekyll'
require 'json'
require 'net/http'
require '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
attr_reader :site
def initialize
@site = Jekyll::Site.new(Jekyll.configuration({}))
@site.read
@jekyll_filter = JekyllFilter.new
@content_template = Liquid::Template.parse('')
@handle = ENV['BLUESKY_HANDLE']
@password = ENV['BLUESKY_PASSWORD']
@did = generate_did
@api_key = generate_api_key(@did)
end
def post_all
site.posts.docs.each do |post|
post(post) if should_post?(post)
end
end
def generate_did
uri = URI.parse('https://bsky.social/xrpc/com.atproto.identity.resolveHandle')
params = { handle: @handle }
uri.query = URI.encode_www_form(params)
response = Net::HTTP.get_response(uri)
raise Error, 'DID identification failed' unless response.is_a?(Net::HTTPSuccess)
JSON.parse(response.body)['did']
end
def generate_api_key(did)
uri = URI.parse('https://bsky.social/xrpc/com.atproto.server.createSession')
request = Net::HTTP::Post.new(uri)
request.content_type = 'application/json'
request.body = JSON.dump({
'identifier' => did,
'password' => @password
})
req_options = {
use_ssl: uri.scheme == 'https'
}
response = Net::HTTP.start(uri.hostname, uri.port, req_options) do |http|
http.request(request)
end
raise Error, 'API Key generation failed' unless response.is_a?(Net::HTTPSuccess)
JSON.parse(response.body)['accessJwt']
end
def post(post)
puts "Posting: #{url(post)}"
message = "#{url(post)}\n\n#{post.data['title']}"
status = post_status(message)
puts "Posted: #{url(post)} to #{status['url']}"
bluesky_status_url = status['url']
save_status(post, bluesky_status_url)
end
def save_status(post, bluesky_status_url)
post_path = post.path
post_content = File.read(post_path)
new_post_content = post_content.gsub(
/^bluesky_status_url: .*/,
"bluesky_status_url: #{bluesky_status_url}"
)
File.write(post_path, new_post_content)
end
private
def should_post?(post)
posted_url = post.data['bluesky_status_url']
!posted_url.nil? && !(posted_url || '').match?(%r{https\S*/\d+})
end
def post_status(status)
uri = URI.parse('https://bsky.social/xrpc/com.atproto.repo.createRecord')
request = Net::HTTP::Post.new(uri)
request.content_type = 'application/json'
request['Authorization'] = "Bearer #{@api_key}"
request.body = JSON.dump({
'collection' => 'app.bsky.feed.post',
'repo' => @did,
'record' => {
'text' => status,
'facets' => parse_facets(status),
'createdAt' => Time.now.strftime('%Y-%m-%dT%H:%M:%S.%3NZ'),
'$type' => 'app.bsky.feed.post'
}
})
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, 'Status post request failed' unless res.is_a?(Net::HTTPSuccess)
resp_body = JSON.parse(res.body)
resp_body['url'] = "https://bsky.app/profile/#{@handle}/post/#{resp_body['uri'].split('/').last}"
resp_body
end
def url(post)
"#{site.config['url']}#{post.url}"
end
def parse_mentions(text)
spans = []
# regex based on: https://atproto.com/specs/handle#handle-identifier-syntax
mention_regex = /[$|\W](@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)/
text_bytes = text.encode('UTF-8')
text_bytes.scan(mention_regex) do |match|
spans << {
start: $LAST_MATCH_INFO.offset(1).first,
end: $LAST_MATCH_INFO.offset(1).last,
handle: match[0][1..]
}
end
spans
end
def parse_urls(text)
spans = []
text_bytes = text.encode('UTF-8')
URI.extract(text, %w[http https]).each do |url|
spans << {
start: text_bytes.index(url),
end: text_bytes.index(url) + url.length,
url: url
}
end
spans
end
def parse_facets(text)
facets = []
parse_mentions(text).each do |m|
uri = URI('https://bsky.social/xrpc/com.atproto.identity.resolveHandle')
params = { handle: m[:handle] }
uri.query = URI.encode_www_form(params)
response = Net::HTTP.get_response(uri)
next if response.code.to_i == 400
did = JSON.parse(response.body)['did']
facets << {
index: {
byteStart: m[:start],
byteEnd: m[:end]
},
features: [{ "$type": 'app.bsky.richtext.facet#mention', "did": did }]
}
end
parse_urls(text).each do |u|
facets << {
index: {
byteStart: u[:start],
byteEnd: u[:end]
},
features: [
{
"$type": 'app.bsky.richtext.facet#link',
# NOTE: URI ("I") not URL ("L")
"uri": u[:url]
}
]
}
end
facets
end
def excerpt(post)
post.data['description'] ||
@content_template.render('x' => @jekyll_filter.markdownify(post.content))
end
end
end
POSSE::Bluesky.new.post_all
Future Work
Things I think I’ll build into this in the future:
- Add preview images/description for link facets
- If the post contents is small enough to fit in the character limit of a Bluesky post, include the full contents instead of just the title
- Reply in thread if
reply_to
is present and is a Bluesky URL - Get reply counts/contents? and add to post frontmatter for rendering on the site