Toot toooooooot (Bluesky-Mastodon cross-poster)
1require 'io/console' 2 3require_relative 'bluesky_account' 4require_relative 'mastodon_account' 5require_relative 'post_history' 6 7class Tootify 8 attr_accessor :check_interval 9 10 def initialize 11 @bluesky = BlueskyAccount.new 12 @mastodon = MastodonAccount.new 13 @history = PostHistory.new 14 @check_interval = 60 15 end 16 17 def login_bluesky(handle) 18 handle = handle.gsub(/^@/, '') 19 20 print "App password: " 21 password = STDIN.noecho(&:gets).chomp 22 puts 23 24 @bluesky.login_with_password(handle, password) 25 end 26 27 def login_mastodon(handle) 28 print "Email: " 29 email = STDIN.gets.chomp 30 31 print "Password: " 32 password = STDIN.noecho(&:gets).chomp 33 puts 34 35 @mastodon.oauth_login(handle, email, password) 36 end 37 38 def sync 39 begin 40 likes = @bluesky.fetch_likes 41 rescue Minisky::ExpiredTokenError => e 42 @bluesky.log_in 43 likes = @bluesky.fetch_likes 44 end 45 46 records = [] 47 48 likes.each do |r| 49 like_uri = r['uri'] 50 post_uri = r['value']['subject']['uri'] 51 repo, collection, rkey = post_uri.split('/')[2..4] 52 53 next unless repo == @bluesky.did && collection == 'app.bsky.feed.post' 54 55 begin 56 record = @bluesky.fetch_record(repo, collection, rkey) 57 rescue Minisky::ClientErrorResponse => e 58 puts "Record not found: #{post_uri}" 59 @bluesky.delete_record_at(like_uri) 60 next 61 end 62 63 if reply = record['value']['reply'] 64 parent_uri = reply['parent']['uri'] 65 prepo = parent_uri.split('/')[2] 66 67 if prepo != @bluesky.did 68 puts "Skipping reply to someone else" 69 @bluesky.delete_record_at(like_uri) 70 next 71 else 72 # self-reply, we'll try to cross-post it 73 end 74 end 75 76 records << [record['value'], rkey, like_uri] 77 end 78 79 records.sort_by { |x| x[0]['createdAt'] }.each do |record, rkey, like_uri| 80 mastodon_parent_id = nil 81 82 if reply = record['reply'] 83 parent_uri = reply['parent']['uri'] 84 prkey = parent_uri.split('/')[4] 85 86 if parent_id = @history[prkey] 87 mastodon_parent_id = parent_id 88 else 89 puts "Skipping reply to a post that wasn't cross-posted" 90 @bluesky.delete_record_at(like_uri) 91 next 92 end 93 end 94 95 response = post_to_mastodon(record, mastodon_parent_id) 96 p response 97 98 @history.add(rkey, response['id']) 99 @bluesky.delete_record_at(like_uri) 100 end 101 end 102 103 def watch 104 loop do 105 sync 106 sleep @check_interval 107 end 108 end 109 110 def post_to_mastodon(record, mastodon_parent_id = nil) 111 p record 112 113 text = expand_facets(record) 114 115 if link = link_embed(record) 116 append_link(text, link) unless text.include?(link) 117 end 118 119 if quote_uri = quoted_post(record) 120 repo, collection, rkey = quote_uri.split('/')[2..4] 121 122 if collection == 'app.bsky.feed.post' 123 bsky_url = bsky_post_link(repo, rkey) 124 append_link(text, bsky_url) unless text.include?(bsky_url) 125 end 126 end 127 128 if images = attached_images(record) 129 media_ids = [] 130 131 images.each do |embed| 132 alt = embed['alt'] 133 cid = embed['image']['ref']['$link'] 134 mime = embed['image']['mimeType'] 135 136 if alt.length > @mastodon.max_alt_length 137 alt = alt[0...@mastodon.max_alt_length - 3] + "(…)" 138 end 139 140 data = @bluesky.fetch_blob(cid) 141 142 uploaded_media = @mastodon.upload_media(data, cid, mime, alt) 143 media_ids << uploaded_media['id'] 144 end 145 elsif embed = attached_video(record) 146 alt = embed['alt'] 147 cid = embed['video']['ref']['$link'] 148 mime = embed['video']['mimeType'] 149 150 if alt.length > @mastodon.max_alt_length 151 alt = alt[0...@mastodon.max_alt_length - 3] + "(…)" 152 end 153 154 data = @bluesky.fetch_blob(cid) 155 156 uploaded_media = @mastodon.upload_media(data, cid, mime, alt) 157 media_ids = [uploaded_media['id']] 158 end 159 160 if tags = record['tags'] 161 text += "\n\n" + tags.map { |t| '#' + t.gsub(' ', '') }.join(' ') 162 end 163 164 @mastodon.post_status(text, media_ids, mastodon_parent_id) 165 end 166 167 def expand_facets(record) 168 bytes = record['text'].bytes 169 offset = 0 170 171 if facets = record['facets'] 172 facets.sort_by { |f| f['index']['byteStart'] }.each do |f| 173 if link = f['features'].detect { |ft| ft['$type'] == 'app.bsky.richtext.facet#link' } 174 left = f['index']['byteStart'] 175 right = f['index']['byteEnd'] 176 content = link['uri'].bytes 177 178 bytes[(left + offset) ... (right + offset)] = content 179 offset += content.length - (right - left) 180 end 181 end 182 end 183 184 bytes.pack('C*').force_encoding('UTF-8') 185 end 186 187 def link_embed(record) 188 if embed = record['embed'] 189 case embed['$type'] 190 when 'app.bsky.embed.external' 191 embed['external']['uri'] 192 when 'app.bsky.embed.recordWithMedia' 193 embed['media']['external'] && embed['media']['external']['uri'] 194 else 195 nil 196 end 197 end 198 end 199 200 def quoted_post(record) 201 if embed = record['embed'] 202 case embed['$type'] 203 when 'app.bsky.embed.record' 204 embed['record']['uri'] 205 when 'app.bsky.embed.recordWithMedia' 206 embed['record']['record']['uri'] 207 else 208 nil 209 end 210 end 211 end 212 213 def attached_images(record) 214 if embed = record['embed'] 215 case embed['$type'] 216 when 'app.bsky.embed.images' 217 embed['images'] 218 when 'app.bsky.embed.recordWithMedia' 219 if embed['media']['$type'] == 'app.bsky.embed.images' 220 embed['media']['images'] 221 else 222 nil 223 end 224 else 225 nil 226 end 227 end 228 end 229 230 def attached_video(record) 231 if embed = record['embed'] 232 case embed['$type'] 233 when 'app.bsky.embed.video' 234 embed 235 when 'app.bsky.embed.recordWithMedia' 236 if embed['media']['$type'] == 'app.bsky.embed.video' 237 embed['media'] 238 else 239 nil 240 end 241 else 242 nil 243 end 244 end 245 end 246 247 def append_link(text, link) 248 text << ' ' unless text.end_with?(' ') 249 text << link 250 end 251 252 def bsky_post_link(repo, rkey) 253 "https://bsky.app/profile/#{repo}/post/#{rkey}" 254 end 255end