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 |image| 132 alt = image['alt'] 133 cid = image['image']['ref']['$link'] 134 mime = image['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 end 146 147 if tags = record['tags'] 148 text += "\n\n" + tags.map { |t| '#' + t.gsub(' ', '') }.join(' ') 149 end 150 151 @mastodon.post_status(text, media_ids, mastodon_parent_id) 152 end 153 154 def expand_facets(record) 155 bytes = record['text'].bytes 156 offset = 0 157 158 if facets = record['facets'] 159 facets.sort_by { |f| f['index']['byteStart'] }.each do |f| 160 if link = f['features'].detect { |ft| ft['$type'] == 'app.bsky.richtext.facet#link' } 161 left = f['index']['byteStart'] 162 right = f['index']['byteEnd'] 163 content = link['uri'].bytes 164 165 bytes[(left + offset) ... (right + offset)] = content 166 offset += content.length - (right - left) 167 end 168 end 169 end 170 171 bytes.pack('C*').force_encoding('UTF-8') 172 end 173 174 def link_embed(record) 175 if embed = record['embed'] 176 case embed['$type'] 177 when 'app.bsky.embed.external' 178 embed['external']['uri'] 179 when 'app.bsky.embed.recordWithMedia' 180 embed['media']['external'] && embed['media']['external']['uri'] 181 else 182 nil 183 end 184 end 185 end 186 187 def quoted_post(record) 188 if embed = record['embed'] 189 case embed['$type'] 190 when 'app.bsky.embed.record' 191 embed['record']['uri'] 192 when 'app.bsky.embed.recordWithMedia' 193 embed['record']['record']['uri'] 194 else 195 nil 196 end 197 end 198 end 199 200 def attached_images(record) 201 if embed = record['embed'] 202 case embed['$type'] 203 when 'app.bsky.embed.images' 204 embed['images'] 205 when 'app.bsky.embed.recordWithMedia' 206 embed['media']['images'] 207 else 208 nil 209 end 210 end 211 end 212 213 def append_link(text, link) 214 text << ' ' unless text.end_with?(' ') 215 text << link 216 end 217 218 def bsky_post_link(repo, rkey) 219 "https://bsky.app/profile/#{repo}/post/#{rkey}" 220 end 221end