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 records = [] 45 46 likes.each do |r| 47 like_uri = r['uri'] 48 post_uri = r['value']['subject']['uri'] 49 repo, collection, rkey = post_uri.split('/')[2..4] 50 51 next unless repo == @bluesky.did && collection == 'app.bsky.feed.post' 52 53 begin 54 record = @bluesky.fetch_record(repo, collection, rkey) 55 rescue Minisky::ClientErrorResponse => e 56 puts "Record not found: #{post_uri}" 57 @bluesky.delete_record_at(like_uri) 58 next 59 end 60 61 if record['value']['reply'] 62 puts "Skipping reply" 63 @bluesky.delete_record_at(like_uri) 64 next 65 end 66 67 records << [record['value'], like_uri] 68 end 69 70 records.sort_by { |x| x[0]['createdAt'] }.each do |record, like_uri| 71 post_to_mastodon(record) 72 @bluesky.delete_record_at(like_uri) 73 end 74 end 75 76 def watch 77 loop do 78 sync 79 sleep @check_interval 80 end 81 end 82 83 def post_to_mastodon(record) 84 p record 85 86 text = expand_facets(record) 87 88 if link = link_embed(record) 89 append_link(text, link) unless text.include?(link) 90 end 91 92 if quote_uri = quoted_post(record) 93 repo, collection, rkey = quote_uri.split('/')[2..4] 94 95 if collection == 'app.bsky.feed.post' 96 bsky_url = bsky_post_link(repo, rkey) 97 append_link(text, bsky_url) unless text.include?(bsky_url) 98 end 99 end 100 101 if images = attached_images(record) 102 media_ids = [] 103 104 images.each do |image| 105 alt = image['alt'] 106 cid = image['image']['ref']['$link'] 107 mime = image['image']['mimeType'] 108 109 if alt.length > @mastodon.max_alt_length 110 alt = alt[0...@mastodon.max_alt_length - 3] + "(…)" 111 end 112 113 data = @bluesky.fetch_blob(cid) 114 115 uploaded_media = @mastodon.upload_media(data, cid, mime, alt) 116 media_ids << uploaded_media['id'] 117 end 118 end 119 120 if tags = record['tags'] 121 text += "\n\n" + tags.map { |t| '#' + t.gsub(' ', '') }.join(' ') 122 end 123 124 p @mastodon.post_status(text, media_ids) 125 end 126 127 def expand_facets(record) 128 bytes = record['text'].bytes 129 offset = 0 130 131 if facets = record['facets'] 132 facets.sort_by { |f| f['index']['byteStart'] }.each do |f| 133 if link = f['features'].detect { |ft| ft['$type'] == 'app.bsky.richtext.facet#link' } 134 left = f['index']['byteStart'] 135 right = f['index']['byteEnd'] 136 content = link['uri'].bytes 137 138 bytes[(left + offset) ... (right + offset)] = content 139 offset += content.length - (right - left) 140 end 141 end 142 end 143 144 bytes.pack('C*').force_encoding('UTF-8') 145 end 146 147 def link_embed(record) 148 if embed = record['embed'] 149 case embed['$type'] 150 when 'app.bsky.embed.external' 151 embed['external']['uri'] 152 when 'app.bsky.embed.recordWithMedia' 153 embed['media']['external'] && embed['media']['external']['uri'] 154 else 155 nil 156 end 157 end 158 end 159 160 def quoted_post(record) 161 if embed = record['embed'] 162 case embed['$type'] 163 when 'app.bsky.embed.record' 164 embed['record']['uri'] 165 when 'app.bsky.embed.recordWithMedia' 166 embed['record']['record']['uri'] 167 else 168 nil 169 end 170 end 171 end 172 173 def attached_images(record) 174 if embed = record['embed'] 175 case embed['$type'] 176 when 'app.bsky.embed.images' 177 embed['images'] 178 when 'app.bsky.embed.recordWithMedia' 179 embed['media']['images'] 180 else 181 nil 182 end 183 end 184 end 185 186 def append_link(text, link) 187 text << ' ' unless text.end_with?(' ') 188 text << link 189 end 190 191 def bsky_post_link(repo, rkey) 192 "https://bsky.app/profile/#{repo}/post/#{rkey}" 193 end 194end