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 begin 63 record = @bluesky.fetch_record(repo, collection, rkey) 64 rescue Minisky::ClientErrorResponse => e 65 puts "Record not found: #{post_uri}" 66 @bluesky.delete_record_at(like_uri) 67 next 68 end 69 70 if reply = record['value']['reply'] 71 parent_uri = reply['parent']['uri'] 72 prepo = parent_uri.split('/')[2] 73 74 if prepo != @bluesky.did 75 puts "Skipping reply to someone else" 76 @bluesky.delete_record_at(like_uri) 77 next 78 else 79 # self-reply, we'll try to cross-post it 80 end 81 end 82 83 records << [record['value'], rkey, like_uri] 84 end 85 86 records.sort_by { |x| x[0]['createdAt'] }.each do |record, rkey, like_uri| 87 mastodon_parent_id = nil 88 89 if reply = record['reply'] 90 parent_uri = reply['parent']['uri'] 91 parent_rkey = parent_uri.split('/')[4] 92 93 if parent_post = Post.find_by(bluesky_rkey: parent_rkey) 94 mastodon_parent_id = parent_post.mastodon_id 95 else 96 puts "Skipping reply to a post that wasn't cross-posted" 97 @bluesky.delete_record_at(like_uri) 98 next 99 end 100 end 101 102 response = post_to_mastodon(record, mastodon_parent_id) 103 p response 104 105 Post.create!(bluesky_rkey: rkey, mastodon_id: response['id']) 106 107 @bluesky.delete_record_at(like_uri) 108 end 109 end 110 111 def watch 112 loop do 113 sync 114 sleep @check_interval 115 end 116 end 117 118 def post_to_mastodon(record, mastodon_parent_id = nil) 119 p record 120 121 text = expand_facets(record) 122 123 if link = link_embed(record) 124 append_link(text, link) unless text.include?(link) 125 end 126 127 if quote_uri = quoted_post(record) 128 repo, collection, rkey = quote_uri.split('/')[2..4] 129 130 if collection == 'app.bsky.feed.post' 131 link_to_append = bsky_post_link(repo, rkey) 132 133 if @config['extract_link_from_quotes'] 134 quoted_record = fetch_record_by_at_uri(quote_uri) 135 136 quote_link = link_embed(quoted_record) 137 138 if quote_link.nil? 139 text_links = links_from_facets(quoted_record) 140 quote_link = text_links.first if text_links.length == 1 141 end 142 143 if quote_link 144 link_to_append = quote_link 145 end 146 end 147 148 append_link(text, link_to_append) unless text.include?(link_to_append) 149 end 150 end 151 152 if images = attached_images(record) 153 media_ids = [] 154 155 images.each do |embed| 156 alt = embed['alt'] 157 cid = embed['image']['ref']['$link'] 158 mime = embed['image']['mimeType'] 159 160 if alt && alt.length > @mastodon.max_alt_length 161 alt = alt[0...@mastodon.max_alt_length - 3] + "(…)" 162 end 163 164 data = @bluesky.fetch_blob(cid) 165 166 uploaded_media = @mastodon.upload_media(data, cid, mime, alt) 167 media_ids << uploaded_media['id'] 168 end 169 elsif embed = attached_video(record) 170 alt = embed['alt'] 171 cid = embed['video']['ref']['$link'] 172 mime = embed['video']['mimeType'] 173 174 if alt && alt.length > @mastodon.max_alt_length 175 alt = alt[0...@mastodon.max_alt_length - 3] + "(…)" 176 end 177 178 data = @bluesky.fetch_blob(cid) 179 180 uploaded_media = @mastodon.upload_media(data, cid, mime, alt) 181 media_ids = [uploaded_media['id']] 182 end 183 184 if tags = record['tags'] 185 text += "\n\n" + tags.map { |t| '#' + t.gsub(' ', '') }.join(' ') 186 end 187 188 @mastodon.post_status(text, media_ids, mastodon_parent_id) 189 end 190 191 def fetch_record_by_at_uri(quote_uri) 192 repo, collection, rkey = quote_uri.split('/')[2..4] 193 pds = DID.new(repo).get_document.pds_endpoint 194 sky = Minisky.new(pds, nil) 195 resp = sky.get_request('com.atproto.repo.getRecord', { repo: repo, collection: collection, rkey: rkey }) 196 resp['value'] 197 end 198 199 def expand_facets(record) 200 bytes = record['text'].bytes 201 offset = 0 202 203 if facets = record['facets'] 204 facets.sort_by { |f| f['index']['byteStart'] }.each do |f| 205 if link = f['features'].detect { |ft| ft['$type'] == 'app.bsky.richtext.facet#link' } 206 left = f['index']['byteStart'] 207 right = f['index']['byteEnd'] 208 content = link['uri'].bytes 209 210 bytes[(left + offset) ... (right + offset)] = content 211 offset += content.length - (right - left) 212 end 213 end 214 end 215 216 bytes.pack('C*').force_encoding('UTF-8') 217 end 218 219 def links_from_facets(record) 220 links = [] 221 222 if facets = record['facets'] 223 facets.each do |f| 224 if link = f['features'].detect { |ft| ft['$type'] == 'app.bsky.richtext.facet#link' } 225 links << link['uri'] 226 end 227 end 228 end 229 230 links.reject { |x| x.start_with?('https://bsky.app/hashtag/') } 231 end 232 233 def link_embed(record) 234 if embed = record['embed'] 235 case embed['$type'] 236 when 'app.bsky.embed.external' 237 embed['external']['uri'] 238 when 'app.bsky.embed.recordWithMedia' 239 embed['media']['external'] && embed['media']['external']['uri'] 240 else 241 nil 242 end 243 end 244 end 245 246 def quoted_post(record) 247 if embed = record['embed'] 248 case embed['$type'] 249 when 'app.bsky.embed.record' 250 embed['record']['uri'] 251 when 'app.bsky.embed.recordWithMedia' 252 embed['record']['record']['uri'] 253 else 254 nil 255 end 256 end 257 end 258 259 def attached_images(record) 260 if embed = record['embed'] 261 case embed['$type'] 262 when 'app.bsky.embed.images' 263 embed['images'] 264 when 'app.bsky.embed.recordWithMedia' 265 if embed['media']['$type'] == 'app.bsky.embed.images' 266 embed['media']['images'] 267 else 268 nil 269 end 270 else 271 nil 272 end 273 end 274 end 275 276 def attached_video(record) 277 if embed = record['embed'] 278 case embed['$type'] 279 when 'app.bsky.embed.video' 280 embed 281 when 'app.bsky.embed.recordWithMedia' 282 if embed['media']['$type'] == 'app.bsky.embed.video' 283 embed['media'] 284 else 285 nil 286 end 287 else 288 nil 289 end 290 end 291 end 292 293 def append_link(text, link) 294 if link =~ %r{^https://bsky\.app/profile/.+/post/.+} 295 text << "\n" unless text.end_with?("\n") 296 text << "\n" 297 text << "RE: " + link 298 else 299 text << ' ' unless text.end_with?(' ') 300 text << link 301 end 302 end 303 304 def bsky_post_link(repo, rkey) 305 "https://bsky.app/profile/#{repo}/post/#{rkey}" 306 end 307end