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