Toot toooooooot (Bluesky-Mastodon cross-poster)
1require 'io/console' 2 3require_relative 'bluesky_account' 4require_relative 'mastodon_account' 5 6class Tootify 7 attr_accessor :check_interval 8 9 def initialize 10 @bluesky = BlueskyAccount.new 11 @mastodon = MastodonAccount.new 12 @check_interval = 60 13 end 14 15 def login_bluesky(handle) 16 handle = handle.gsub(/^@/, '') 17 18 print "App password: " 19 password = STDIN.noecho(&:gets).chomp 20 puts 21 22 @bluesky.login_with_password(handle, password) 23 end 24 25 def login_mastodon(handle) 26 print "Email: " 27 email = STDIN.gets.chomp 28 29 print "Password: " 30 password = STDIN.noecho(&:gets).chomp 31 puts 32 33 @mastodon.oauth_login(handle, email, password) 34 end 35 36 def sync 37 begin 38 likes = @bluesky.fetch_likes 39 rescue Minisky::ExpiredTokenError => e 40 @bluesky.log_in 41 likes = @bluesky.fetch_likes 42 end 43 44 likes.each do |r| 45 like_uri = r['uri'] 46 post_uri = r['value']['subject']['uri'] 47 repo, collection, rkey = post_uri.split('/')[2..4] 48 49 next unless repo == @bluesky.did && collection == 'app.bsky.feed.post' 50 51 begin 52 record = @bluesky.fetch_record(repo, collection, rkey) 53 rescue Minisky::ClientErrorResponse => e 54 puts "Record not found: #{post_uri}" 55 @bluesky.delete_record_at(like_uri) 56 next 57 end 58 59 if record['value']['reply'] 60 puts "Skipping reply" 61 @bluesky.delete_record_at(like_uri) 62 next 63 end 64 65 post_to_mastodon(record['value']) 66 67 @bluesky.delete_record_at(like_uri) 68 end 69 end 70 71 def watch 72 loop do 73 sync 74 sleep @check_interval 75 end 76 end 77 78 def post_to_mastodon(record) 79 p record 80 81 text = expand_facets(record) 82 83 if link = link_embed(record) 84 append_link(text, link) unless text.include?(link) 85 end 86 87 if quote_uri = quoted_post(record) 88 repo, collection, rkey = quote_uri.split('/')[2..4] 89 90 if collection == 'app.bsky.feed.post' 91 bsky_url = bsky_post_link(repo, rkey) 92 append_link(text, bsky_url) unless text.include?(bsky_url) 93 end 94 end 95 96 if images = attached_images(record) 97 media_ids = [] 98 99 images.each do |image| 100 alt = image['alt'] 101 cid = image['image']['ref']['$link'] 102 mime = image['image']['mimeType'] 103 104 data = @bluesky.fetch_blob(cid) 105 106 uploaded_media = @mastodon.upload_media(data, cid, mime, alt) 107 media_ids << uploaded_media['id'] 108 end 109 end 110 111 if tags = record['tags'] 112 text += "\n\n" + tags.map { |t| '#' + t.gsub(' ', '') }.join(' ') 113 end 114 115 p @mastodon.post_status(text, media_ids) 116 end 117 118 def expand_facets(record) 119 bytes = record['text'].bytes 120 offset = 0 121 122 if facets = record['facets'] 123 facets.sort_by { |f| f['index']['byteStart'] }.each do |f| 124 if link = f['features'].detect { |ft| ft['$type'] == 'app.bsky.richtext.facet#link' } 125 left = f['index']['byteStart'] 126 right = f['index']['byteEnd'] 127 content = link['uri'].bytes 128 129 bytes[(left + offset) ... (right + offset)] = content 130 offset += content.length - (right - left) 131 end 132 end 133 end 134 135 bytes.pack('C*').force_encoding('UTF-8') 136 end 137 138 def link_embed(record) 139 if embed = record['embed'] 140 case embed['$type'] 141 when 'app.bsky.embed.external' 142 embed['external']['uri'] 143 when 'app.bsky.embed.recordWithMedia' 144 embed['media']['external'] && embed['media']['external']['uri'] 145 else 146 nil 147 end 148 end 149 end 150 151 def quoted_post(record) 152 if embed = record['embed'] 153 case embed['$type'] 154 when 'app.bsky.embed.record' 155 embed['record']['uri'] 156 when 'app.bsky.embed.recordWithMedia' 157 embed['record']['record']['uri'] 158 else 159 nil 160 end 161 end 162 end 163 164 def attached_images(record) 165 if embed = record['embed'] 166 case embed['$type'] 167 when 'app.bsky.embed.images' 168 embed['images'] 169 when 'app.bsky.embed.recordWithMedia' 170 embed['media']['images'] 171 else 172 nil 173 end 174 end 175 end 176 177 def append_link(text, link) 178 text << ' ' unless text.end_with?(' ') 179 text << link 180 end 181 182 def bsky_post_link(repo, rkey) 183 "https://bsky.app/profile/#{repo}/post/#{rkey}" 184 end 185end