Toot toooooooot (Bluesky-Mastodon cross-poster)
1require 'io/console' 2 3require_relative 'bluesky_account' 4require_relative 'mastodon_account' 5 6class Tootify 7 attr_accessor :check_interval 8 9 def initialize 10 @bluesky = BlueskyAccount.new 11 @mastodon = MastodonAccount.new 12 @check_interval = 60 13 end 14 15 def login_bluesky(handle) 16 handle = handle.gsub(/^@/, '') 17 18 print "App password: " 19 password = STDIN.noecho(&:gets).chomp 20 puts 21 22 @bluesky.login_with_password(handle, password) 23 end 24 25 def login_mastodon(handle) 26 print "Email: " 27 email = STDIN.gets.chomp 28 29 print "Password: " 30 password = STDIN.noecho(&:gets).chomp 31 puts 32 33 @mastodon.oauth_login(handle, email, password) 34 end 35 36 def sync 37 likes = @bluesky.fetch_likes 38 39 likes.each do |r| 40 like_uri = r['uri'] 41 post_uri = r['value']['subject']['uri'] 42 repo, collection, rkey = post_uri.split('/')[2..4] 43 44 next unless repo == @bluesky.did && collection == 'app.bsky.feed.post' 45 46 begin 47 record = @bluesky.fetch_record(repo, collection, rkey) 48 rescue Minisky::ClientErrorResponse => e 49 puts "Record not found: #{post_uri}" 50 @bluesky.delete_record_at(like_uri) 51 next 52 end 53 54 post_to_mastodon(record['value']) 55 56 @bluesky.delete_record_at(like_uri) 57 end 58 end 59 60 def watch 61 loop do 62 sync 63 sleep @check_interval 64 end 65 end 66 67 def post_to_mastodon(record) 68 p record 69 70 text = expand_facets(record) 71 72 if link = link_embed(record) 73 append_link(text, link) unless text.include?(link) 74 end 75 76 if quote_uri = quoted_post(record) 77 repo, collection, rkey = quote_uri.split('/')[2..4] 78 79 if collection == 'app.bsky.feed.post' 80 bsky_url = bsky_post_link(repo, rkey) 81 append_link(text, bsky_url) unless text.include?(bsky_url) 82 end 83 end 84 85 if images = attached_images(record) 86 media_ids = [] 87 88 images.each do |image| 89 alt = image['alt'] 90 cid = image['image']['ref']['$link'] 91 mime = image['image']['mimeType'] 92 93 data = @bluesky.fetch_blob(cid) 94 95 uploaded_media = @mastodon.upload_media(data, cid, mime, alt) 96 media_ids << uploaded_media['id'] 97 end 98 end 99 100 p @mastodon.post_status(text, media_ids) 101 end 102 103 def expand_facets(record) 104 bytes = record['text'].bytes 105 offset = 0 106 107 if facets = record['facets'] 108 facets.sort_by { |f| f['index']['byteStart'] }.each do |f| 109 if link = f['features'].detect { |ft| ft['$type'] == 'app.bsky.richtext.facet#link' } 110 left = f['index']['byteStart'] 111 right = f['index']['byteEnd'] 112 content = link['uri'].bytes 113 114 bytes[(left + offset) ... (right + offset)] = content 115 offset += content.length - (right - left) 116 end 117 end 118 end 119 120 bytes.pack('C*').force_encoding('UTF-8') 121 end 122 123 def link_embed(record) 124 if embed = record['embed'] 125 case embed['$type'] 126 when 'app.bsky.embed.external' 127 embed['external']['uri'] 128 when 'app.bsky.embed.recordWithMedia' 129 embed['media']['external'] && embed['media']['external']['uri'] 130 else 131 nil 132 end 133 end 134 end 135 136 def quoted_post(record) 137 if embed = record['embed'] 138 case embed['$type'] 139 when 'app.bsky.embed.record' 140 embed['record']['uri'] 141 when 'app.bsky.embed.recordWithMedia' 142 embed['record']['record']['uri'] 143 else 144 nil 145 end 146 end 147 end 148 149 def attached_images(record) 150 if embed = record['embed'] 151 case embed['$type'] 152 when 'app.bsky.embed.images' 153 embed['images'] 154 when 'app.bsky.embed.recordWithMedia' 155 embed['media']['images'] 156 else 157 nil 158 end 159 end 160 end 161 162 def append_link(text, link) 163 text << ' ' unless text.end_with?(' ') 164 text << link 165 end 166 167 def bsky_post_link(repo, rkey) 168 "https://bsky.app/profile/#{repo}/post/#{rkey}" 169 end 170end