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 post_to_mastodon(record['value']) 55 56 @bluesky.delete_record_at(like_uri) 57 end 58 end 59 60 def watch 61 loop do 62 sync 63 sleep @check_interval 64 end 65 end 66 67 def post_to_mastodon(record) 68 p record 69 70 text = expand_facets(record) 71 72 if link = link_embed(record) 73 append_link(text, link) unless text.include?(link) 74 end 75 76 if quote_uri = quoted_post(record) 77 repo, collection, rkey = quote_uri.split('/')[2..4] 78 79 if collection == 'app.bsky.feed.post' 80 bsky_url = bsky_post_link(repo, rkey) 81 append_link(text, bsky_url) unless text.include?(bsky_url) 82 end 83 end 84 85 p @mastodon.post_status(text) 86 end 87 88 def expand_facets(record) 89 bytes = record['text'].bytes 90 offset = 0 91 92 if facets = record['facets'] 93 facets.sort_by { |f| f['index']['byteStart'] }.each do |f| 94 if link = f['features'].detect { |ft| ft['$type'] == 'app.bsky.richtext.facet#link' } 95 left = f['index']['byteStart'] 96 right = f['index']['byteEnd'] 97 content = link['uri'].bytes 98 99 bytes[(left + offset) ... (right + offset)] = content 100 offset += content.length - (right - left) 101 end 102 end 103 end 104 105 bytes.pack('C*').force_encoding('UTF-8') 106 end 107 108 def link_embed(record) 109 if embed = record['embed'] 110 case embed['$type'] 111 when 'app.bsky.embed.external' 112 embed['external']['uri'] 113 when 'app.bsky.embed.recordWithMedia' 114 embed['media']['external'] && embed['media']['external']['uri'] 115 else 116 nil 117 end 118 end 119 end 120 121 def quoted_post(record) 122 if embed = record['embed'] 123 case embed['$type'] 124 when 'app.bsky.embed.record' 125 embed['record']['uri'] 126 when 'app.bsky.embed.recordWithMedia' 127 embed['record']['record']['uri'] 128 else 129 nil 130 end 131 end 132 end 133 134 def append_link(text, link) 135 text << ' ' unless text.end_with?(' ') 136 text << link 137 end 138 139 def bsky_post_link(repo, rkey) 140 "https://bsky.app/profile/#{repo}/post/#{rkey}" 141 end 142end