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