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