Created
January 27, 2025 21:18
-
-
Save searls/b945fef7a32414ce9654ce10222f761b to your computer and use it in GitHub Desktop.
Example code for working with the bluesky API and the bskyrb gem. Copyright Searls LLC, 2025. All rights reserved. Not open source, only made available for your viewing pleasure. For a license to use this code, e-mail [email protected]
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class Platforms::Bsky::AssemblesRichTextFacets | |
# Name of the game here is to take something output by IdentifiesPatternRanges | |
# (which exists to identify unbreakable tokens) and use the embedded metadata | |
# about which of these things are hashtags and links to construct the rich text | |
# facet hash that bluesky wants in order to render links and hashtags | |
def assemble(pattern_ranges) | |
pattern_ranges.map { |range| | |
if range[:type] == :tag | |
facet_for_tag(range) | |
elsif range[:type] == :link | |
facet_for_link(range) | |
end | |
} | |
end | |
private | |
# Per the docs, bsky's byte ranges are inclusive on both ends | |
# https://docs.bsky.app/docs/advanced-guides/post-richtext | |
def bsky_byte_range_for(range) | |
{ | |
"byteStart" => range[:byte_index], | |
"byteEnd" => range[:byte_index] + range[:byte_length] + 1 | |
} | |
end | |
def facet_for_tag(range) | |
{ | |
"$type" => "app.bsky.richtext.facet", | |
"index" => bsky_byte_range_for(range), | |
"features" => [ | |
{ | |
"tag" => range[:substring].delete_prefix("#"), | |
"$type" => "app.bsky.richtext.facet#tag" | |
} | |
] | |
} | |
end | |
def facet_for_link(range) | |
url = UrlUtils.ensure_protocol(range[:substring]) | |
{ | |
"$type" => "app.bsky.richtext.facet", | |
"index" => bsky_byte_range_for(range), | |
"features" => [ | |
{ | |
"uri" => url, | |
"$type" => "app.bsky.richtext.facet#link" | |
} | |
] | |
} | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
require "net/http" | |
require "mime/types" | |
class Platforms::Bsky::AttachesWebCard | |
def attach!(crosspost_config, record_manager) | |
image_data, content_type = download_image(crosspost_config[:og_image]) | |
raise "Failed to download og_image: #{crosspost_config[:og_image]}" unless image_data | |
# Dropping down to do this ourselves b/c the bsky gem assumes you're reading the image from a file | |
# | |
upload_response = HTTParty.post( | |
record_manager.upload_blob_uri(record_manager.session.pds), | |
body: image_data, | |
headers: record_manager.default_authenticated_headers(record_manager.session).merge("Content-Type" => content_type) | |
) | |
raise "Failed to upload og_image: #{crosspost_config[:og_image]} to Bsky. Response: #{TODO}" unless upload_response.success? | |
{ | |
"$type" => "app.bsky.embed.external", | |
"external" => { | |
"uri" => crosspost_config[:url], | |
"title" => crosspost_config[:og_title].presence || crosspost_config[:title], | |
"description" => crosspost_config[:og_description].presence || crosspost_config[:title], | |
"thumb" => upload_response["blob"] | |
} | |
} | |
end | |
private | |
def download_image(image_url) | |
uri = URI.parse(image_url) | |
response = Net::HTTP.get_response(uri) | |
return unless response.is_a?(Net::HTTPSuccess) | |
content_type = response.content_type || MIME::Types.type_for(uri.path).first&.content_type | |
[response.body, content_type] | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class Platforms::Bsky | |
class SyndicatesBskyPost | |
PDS_URL = "https://bsky.social" | |
def initialize | |
@attaches_web_card = AttachesWebCard.new | |
end | |
def syndicate!(crosspost, crosspost_config, crosspost_content, rich_text_facets) | |
session = Bskyrb::Session.new( | |
Bskyrb::Credentials.new( | |
crosspost.account.credentials["email"], | |
crosspost.account.credentials["password"] | |
), PDS_URL | |
) | |
uri = post!(crosspost_config, crosspost_content, rich_text_facets, session) | |
if uri.nil? | |
PublishesCrosspost::Result.new(success?: false, message: "Failed to create Bsky post") | |
else | |
url = uri_to_url(uri, session) | |
crosspost.update!( | |
remote_id: uri, | |
url: url, | |
content: crosspost_content, | |
status: "published", | |
published_at: Now.time | |
) | |
PublishesCrosspost::Result.new(success?: true) | |
end | |
rescue Bskyrb::UnauthorizedError => e | |
PublishesCrosspost::Result.new(success?: false, message: "Failed to authenticate to Bsky (invalid credentials)", error: e) | |
rescue => e | |
PublishesCrosspost::Result.new(success?: false, message: "Failed to syndicate to Bsky", error: e) | |
end | |
private | |
def post!(crosspost_config, crosspost_content, rich_text_facets, session) | |
record_manager = Bskyrb::RecordManager.new(session) | |
embed = @attaches_web_card.attach!(crosspost_config, record_manager) if crosspost_config[:attach_og_card] | |
result = record_manager.create_record({ | |
"collection" => "app.bsky.feed.post", | |
"$type" => "app.bsky.feed.post", | |
"repo" => session.did, | |
"record" => { | |
"$type" => "app.bsky.feed.post", | |
"createdAt" => Now.time.iso8601(3), | |
"text" => crosspost_content, | |
"facets" => rich_text_facets, | |
"embed" => embed | |
} | |
}.compact) | |
result&.dig("uri") | |
end | |
def uri_to_url(uri, session) | |
bsky_handle = DIDKit::Resolver.new.get_validated_handle(session.did) | |
post_id = uri[/[^\/]+$/] | |
"https://bsky.app/profile/#{bsky_handle}/post/#{post_id}" | |
end | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment