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 p @mastodon.post_status(text, media_ids) 107 end 108 109 def expand_facets(record) 110 bytes = record['text'].bytes 111 offset = 0 112 113 if facets = record['facets'] 114 facets.sort_by { |f| f['index']['byteStart'] }.each do |f| 115 if link = f['features'].detect { |ft| ft['$type'] == 'app.bsky.richtext.facet#link' } 116 left = f['index']['byteStart'] 117 right = f['index']['byteEnd'] 118 content = link['uri'].bytes 119 120 bytes[(left + offset) ... (right + offset)] = content 121 offset += content.length - (right - left) 122 end 123 end 124 end 125 126 bytes.pack('C*').force_encoding('UTF-8') 127 end 128 129 def link_embed(record) 130 if embed = record['embed'] 131 case embed['$type'] 132 when 'app.bsky.embed.external' 133 embed['external']['uri'] 134 when 'app.bsky.embed.recordWithMedia' 135 embed['media']['external'] && embed['media']['external']['uri'] 136 else 137 nil 138 end 139 end 140 end 141 142 def quoted_post(record) 143 if embed = record['embed'] 144 case embed['$type'] 145 when 'app.bsky.embed.record' 146 embed['record']['uri'] 147 when 'app.bsky.embed.recordWithMedia' 148 embed['record']['record']['uri'] 149 else 150 nil 151 end 152 end 153 end 154 155 def attached_images(record) 156 if embed = record['embed'] 157 case embed['$type'] 158 when 'app.bsky.embed.images' 159 embed['images'] 160 when 'app.bsky.embed.recordWithMedia' 161 embed['media']['images'] 162 else 163 nil 164 end 165 end 166 end 167 168 def append_link(text, link) 169 text << ' ' unless text.end_with?(' ') 170 text << link 171 end 172 173 def bsky_post_link(repo, rkey) 174 "https://bsky.app/profile/#{repo}/post/#{rkey}" 175 end 176end