作成しているツールでRubyからBlueskyへ投稿したかったので、ShreyanJain9/bskyrb: Ruby Gem for interacting with BlueSky/AT Protocolというライブラリを使うことにした。

使用例を見ると、

require 'bskyrb'
username = 'your_username'
password = 'your_password'
pds_url = 'https://bsky.social'
 
credentials = Bskyrb::Credentials.new(username, password)
session = Bskyrb::Session.new(credentials, pds_url)
bsky = Bskyrb::RecordManager.new(session)
post_uri = bsky.create_post("Hello world from bskyrb!")["uri"]
bsky.like(post_uri)
bsky.repost(post_uri)
bsky.create_reply(post_uri, "Replying to post from bskyrb")

とあり、一見簡単そうに見えるがBlueskyの場合はXやMastodonのAPIとは異なり、投稿するメッセージの中に、

  • URLがあってもリンクは設定されない
  • ハッシュタグがあってもリンクは設定されない
  • URLのコンテンツにOGBタグがあってもリンクカードは表示されない

という仕様であり、なかなか面倒であることがわかった。

そんなわけで上記のドキュメントを参照しつつ、それぞれどうやれば良いのか調べてみた。

リンクの設定

メッセージ中にURLがあれば、その部分を自動的にリンク設定したかったため、Rich text facetsの例にある以下の例を参考に、

{
  text: 'Go to this site',
  facets: [
    {
      index: {
        byteStart: 6,
        byteEnd: 15
      },
      features: [{
        $type: 'app.bsky.richtext.facet#link',
        uri: 'https://example.com'
      }]
    }
  ]
}

以下のようなコードでfacetを作成した。

  def create_facets_for_urls(text)
    facets = []
    text_copy = text.dup
 
    URI.extract(text_copy, ['http', 'https']).each do |url|
      byte_start = text_copy[0, text_copy.index(url)].encode('UTF-8').bytesize
      byte_end = byte_start + url.bytesize
 
      facets << {
        'index' => {'byteStart' => byte_start, 'byteEnd' => byte_end},
        'features' => [
          {
            'uri' => url,
            '$type' => 'app.bsky.richtext.facet#link',
          },
        ],
      }
 
      text_copy[text_copy.index(url), url.length] = "\0" * url.length
    end
 
    facets
  end

ハッシュタグの設定

メッセージ中にハッシュタグがあれば、その部分を自動的にリンク設定したかったため、同じようなロジックでfacetを作成する。

  # Creates facets for hashtags in the text
  def create_facets_for_hashtags(text)
    facets = []
 
    text.scan(/#[\w\p{Han}ー]+/) do |hashtag|
      byte_start = text[0, text.index(hashtag)].encode('UTF-8').bytesize
      byte_end = byte_start + hashtag.bytesize
 
      facets << {
        'index' => {'byteStart' => byte_start, 'byteEnd' => byte_end},
        'features' => [
          {
            'tag' => hashtag.gsub('#', ''),
            '$type' => 'app.bsky.richtext.facet#tag',
          },
        ],
      }
    end
 
    facets
  end

リンクカードの表示

メッセージ中のURLにあるコンテンツにOGPタグがあれば、リンクカードを表示するため以下の流れで対応した。

  1. URLにあるコンテンツにOGPタグがあるかを見に行く。無ければ以降の処理はスキップ
  2. og:title、og:descriptionなど処理に必要なタグ情報を取得する
  3. og:imageのサムネイル画像をダウンロードしBlueskyへアップロードする
  4. 3の戻り値を使いembedを作成する

3〜4のコードはこんなイメージ

  # Handles the creation of embeds by uploading images and generating embed data
  def create_embeds(ogp_data)
    embeds = []
    if ogp_data['og:image']
      begin
        Tempfile.create(['thumbnail', File.extname(URI.parse(ogp_data['og:image']).path)]) do |file|
          file.binmode
          file.write URI.open(ogp_data['og:image']).read
          file.rewind
 
          content_type = determine_content_type(file.path)
          response = upload_image_to_bluesky(file, content_type)
          
          if response && response.parsed_response
            thumb_blob = response.parsed_response['blob']
            embed = create_embed_data(ogp_data, thumb_blob)
            embeds << embed
          else
            warn "Failed to upload image to Bluesky: Invalid response #{response.inspect}"
          end
        end
      rescue StandardError => e
        warn "Failed to process OGP image: #{e.message}"
      end
    end
    embeds
  end
 
  # Creates the embed data structure for Bluesky post
  def create_embed_data(ogp_data, thumb_blob)
    {
      '$type' => 'app.bsky.embed.external',
      'external' => {
        'uri' => ogp_data['og:url'],
        'title' => ogp_data['og:title'],
        'description' => ogp_data['og:description'],
        'thumb' => thumb_blob
      }
    }
  end