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