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