Toot toooooooot (Bluesky-Mastodon cross-poster)

Compare changes

Choose any two refs to compare.

+1 -1
.gitignore
··· 1 .DS_Store 2 - config/*.csv 3 config/*.yml
··· 1 .DS_Store 2 config/*.yml 3 + db/history.sqlite3*
+4 -2
Gemfile
··· 1 source 'https://rubygems.org' 2 3 gem 'didkit', '~> 0.2' 4 - gem 'mastodon-api', git: 'https://github.com/mastodon/mastodon-api.git' 5 - gem 'minisky', '~> 0.4' 6 7 gem 'io-console', '~> 0.5' 8 gem 'json', '~> 2.5' 9 gem 'net-http', '~> 0.2' 10 gem 'uri', '~> 0.13' 11 gem 'yaml', '~> 0.1'
··· 1 source 'https://rubygems.org' 2 3 gem 'didkit', '~> 0.2' 4 + gem 'minisky', '~> 0.5' 5 6 gem 'io-console', '~> 0.5' 7 gem 'json', '~> 2.5' 8 gem 'net-http', '~> 0.2' 9 gem 'uri', '~> 0.13' 10 gem 'yaml', '~> 0.1' 11 + 12 + gem "activerecord", "~> 7.2" 13 + gem "sqlite3", "~> 2.7"
+50 -48
Gemfile.lock
··· 1 - GIT 2 - remote: https://github.com/mastodon/mastodon-api.git 3 - revision: 60b0ed09c3b979cbeb833db0a320b30e1b022a1e 4 - specs: 5 - mastodon-api (2.0.0) 6 - addressable (~> 2.6) 7 - buftok (~> 0) 8 - http (~> 4.0) 9 - oj (~> 3.7) 10 - 11 GEM 12 remote: https://rubygems.org/ 13 specs: 14 - addressable (2.8.7) 15 - public_suffix (>= 2.0.2, < 7.0) 16 - base64 (0.2.0) 17 - bigdecimal (3.1.9) 18 - buftok (0.3.0) 19 - didkit (0.2.3) 20 - domain_name (0.6.20240107) 21 - ffi (1.17.1) 22 - ffi (1.17.1-aarch64-linux-gnu) 23 - ffi (1.17.1-arm64-darwin) 24 - ffi (1.17.1-x86_64-linux-gnu) 25 - ffi-compiler (1.3.2) 26 - ffi (>= 1.15.5) 27 - rake 28 - http (4.4.1) 29 - addressable (~> 2.3) 30 - http-cookie (~> 1.0) 31 - http-form_data (~> 2.2) 32 - http-parser (~> 1.2.0) 33 - http-cookie (1.0.5) 34 - domain_name (~> 0.5) 35 - http-form_data (2.3.0) 36 - http-parser (1.2.3) 37 - ffi-compiler (>= 1.0, < 2.0) 38 - io-console (0.7.2) 39 - json (2.10.2) 40 minisky (0.5.0) 41 base64 (~> 0.1) 42 - net-http (0.4.1) 43 - uri 44 - oj (3.16.9) 45 - bigdecimal (>= 3.0) 46 - ostruct (>= 0.2) 47 - ostruct (0.6.1) 48 - public_suffix (6.0.1) 49 - rake (13.2.1) 50 - uri (0.13.2) 51 - yaml (0.3.0) 52 53 PLATFORMS 54 aarch64-linux ··· 57 x86_64-linux 58 59 DEPENDENCIES 60 didkit (~> 0.2) 61 io-console (~> 0.5) 62 json (~> 2.5) 63 - mastodon-api! 64 - minisky (~> 0.4) 65 net-http (~> 0.2) 66 uri (~> 0.13) 67 yaml (~> 0.1) 68
··· 1 GEM 2 remote: https://rubygems.org/ 3 specs: 4 + activemodel (7.2.3) 5 + activesupport (= 7.2.3) 6 + activerecord (7.2.3) 7 + activemodel (= 7.2.3) 8 + activesupport (= 7.2.3) 9 + timeout (>= 0.4.0) 10 + activesupport (7.2.3) 11 + base64 12 + benchmark (>= 0.3) 13 + bigdecimal 14 + concurrent-ruby (~> 1.0, >= 1.3.1) 15 + connection_pool (>= 2.2.5) 16 + drb 17 + i18n (>= 1.6, < 2) 18 + logger (>= 1.4.2) 19 + minitest (>= 5.1) 20 + securerandom (>= 0.3) 21 + tzinfo (~> 2.0, >= 2.0.5) 22 + base64 (0.3.0) 23 + benchmark (0.5.0) 24 + bigdecimal (4.0.1) 25 + concurrent-ruby (1.3.6) 26 + connection_pool (3.0.2) 27 + didkit (0.3.1) 28 + drb (2.2.3) 29 + i18n (1.14.8) 30 + concurrent-ruby (~> 1.0) 31 + io-console (0.8.2) 32 + json (2.18.0) 33 + logger (1.7.0) 34 + mini_portile2 (2.8.9) 35 minisky (0.5.0) 36 base64 (~> 0.1) 37 + minitest (6.0.1) 38 + prism (~> 1.5) 39 + net-http (0.9.1) 40 + uri (>= 0.11.1) 41 + prism (1.7.0) 42 + securerandom (0.4.1) 43 + sqlite3 (2.9.0) 44 + mini_portile2 (~> 2.8.0) 45 + sqlite3 (2.9.0-aarch64-linux-gnu) 46 + sqlite3 (2.9.0-arm64-darwin) 47 + sqlite3 (2.9.0-x86_64-linux-gnu) 48 + timeout (0.6.0) 49 + tzinfo (2.0.6) 50 + concurrent-ruby (~> 1.0) 51 + uri (0.13.3) 52 + yaml (0.4.0) 53 54 PLATFORMS 55 aarch64-linux ··· 58 x86_64-linux 59 60 DEPENDENCIES 61 + activerecord (~> 7.2) 62 didkit (~> 0.2) 63 io-console (~> 0.5) 64 json (~> 2.5) 65 + minisky (~> 0.5) 66 net-http (~> 0.2) 67 + sqlite3 (~> 2.7) 68 uri (~> 0.13) 69 yaml (~> 0.1) 70
+33 -7
README.md
··· 1 # Tootify ๐Ÿฆ‹โ†’๐Ÿ˜ 2 3 - An experimental Bluesky-to-Mastodon cross-poster 4 5 6 ## What does it do 7 8 - Tootify will allow you to do a selective one-way sync of Bluesky posts to your Mastodon account. 9 10 The way it works lets you easily pick which skeets you want to turn into toots: it scans your recent posts and checks which of them you have liked yourself, and only those posts are reposted. The self-like is automatically removed afterwards. 11 12 - Note: this is an early version so it might be a bit unstable and rough โ€“ but I've been using it for a few months and some other people have tried it too and it generally works. 13 14 15 ## Installation 16 17 - At the moment: 18 19 - git clone https://github.com/mackuba/tootify.git 20 cd tootify 21 bundle install 22 23 ## Usage 24 ··· 37 38 ./tootify watch 39 40 - By default it checks for new skeets every 60 seconds - use the `interval` parameter to customize the interval: 41 42 ./tootify watch --interval=15 43 44 45 ## Credits 46 47 - Copyright ยฉ 2024 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/mackuba.eu)). 48 49 The code is available under the terms of the [zlib license](https://choosealicense.com/licenses/zlib/) (permissive, similar to MIT).
··· 1 # Tootify ๐Ÿฆ‹โ†’๐Ÿ˜ 2 3 + A simple Bluesky-to-Mastodon cross-posting service 4 5 6 ## What does it do 7 8 + Tootify allows you to do a selective one-way sync of Bluesky posts to your Mastodon account. 9 10 The way it works lets you easily pick which skeets you want to turn into toots: it scans your recent posts and checks which of them you have liked yourself, and only those posts are reposted. The self-like is automatically removed afterwards. 11 12 + Currently handles: 13 + 14 + - post with link embeds 15 + - quotes โ€“ posted as "RE: bsky.app/..." 16 + - images (with alt text) 17 + - videos 18 + - threads of multiple chained posts from you 19 20 21 ## Installation 22 23 + To run this tool, you need some reasonably recent version of Ruby installed โ€“ although it's recommended to use a version that's still getting maintainance updates, i.e. currently 3.2+. A recent Ruby version is likely to be preinstalled on most Linux systems, or at least available through the OS's package manager, otherwise you can install one using tools such as [RVM](https://rvm.io), [asdf](https://asdf-vm.com), [ruby-install](https://github.com/postmodern/ruby-install) or [ruby-build](https://github.com/rbenv/ruby-build) (see more installation options on [ruby-lang.org](https://www.ruby-lang.org/en/downloads/)). 24 + 25 + To install the app, run: 26 27 + git clone https://tangled.org/mackuba.eu/tootify 28 cd tootify 29 bundle install 30 + 31 32 ## Usage 33 ··· 46 47 ./tootify watch 48 49 + By default it checks for new skeets every 60 seconds โ€“ use the `interval` parameter to customize the interval: 50 51 ./tootify watch --interval=15 52 53 54 + ## Configs 55 + 56 + Tootify stores configs and data in the `config` folder: 57 + 58 + * `bluesky.yml` โ€“ created when you log in, stores Bluesky user ID/password and access tokens 59 + * `mastodon.yml` โ€“ created when you log in, stores Mastodon user ID/password and access tokens 60 + * `tootify.yml` - optional additional configuration 61 + 62 + The config in `tootify.yml` currently supports one option: 63 + 64 + - `extract_link_from_quotes: true` โ€“ if enabled, posts which are quotes of someone else's post which includes a link will be "collapsed" into a normal post that just includes that link directly without the quote (so the link card on Mastodon will show info about the link and not the quoted bsky.app post) 65 + 66 + There is also an SQLite database file that's automatically created in `db/history.sqlite3`. It stores a mapping between Bluesky and Mastodon post IDs, and is used to maintain reply references in threads. 67 + 68 + 69 ## Credits 70 71 + Copyright ยฉ 2025 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr)). 72 73 The code is available under the terms of the [zlib license](https://choosealicense.com/licenses/zlib/) (permissive, similar to MIT). 74 + 75 + Bug reports and pull requests are welcome ๐Ÿ˜Ž
+1 -3
app/bluesky_account.rb
··· 17 exit 1 18 end 19 20 - pds = did.get_document.pds_endpoint.gsub('https://', '') 21 - 22 - @sky.host = pds 23 @sky.user.id = handle 24 @sky.user.pass = password 25
··· 17 exit 1 18 end 19 20 + @sky.host = did.document.pds_host 21 @sky.user.id = handle 22 @sky.user.pass = password 23
+16
app/database.rb
···
··· 1 + require 'active_record' 2 + 3 + module Database 4 + DB_FILE = File.expand_path(File.join(__dir__, '..', 'db', 'history.sqlite3')) 5 + MIGRATIONS_PATH = File.expand_path(File.join(__dir__, '..', 'db', 'migrate')) 6 + 7 + def self.init 8 + ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: DB_FILE) 9 + run_migrations 10 + end 11 + 12 + def self.run_migrations 13 + migration_context = ActiveRecord::MigrationContext.new(MIGRATIONS_PATH) 14 + migration_context.migrate 15 + end 16 + end
+41 -16
app/mastodon_account.rb
··· 1 - require 'mastodon' 2 require 'yaml' 3 - 4 require_relative 'mastodon_api' 5 6 class MastodonAccount 7 - APP_NAME = "tootify" 8 CONFIG_FILE = File.expand_path(File.join(__dir__, '..', 'config', 'mastodon.yml')) 9 - OAUTH_SCOPES = 'read:accounts read:statuses write:media write:statuses' 10 11 def initialize 12 @config = File.exist?(CONFIG_FILE) ? YAML.load(File.read(CONFIG_FILE)) : {} ··· 20 File.write(CONFIG_FILE, YAML.dump(@config)) 21 end 22 23 - def oauth_login(handle, email, password) 24 instance = handle.split('@').last 25 - app_response = register_oauth_app(instance, OAUTH_SCOPES) 26 27 api = MastodonAPI.new(instance) 28 29 - json = api.oauth_login_with_password( 30 - app_response.client_id, 31 - app_response.client_secret, 32 - email, password, OAUTH_SCOPES 33 - ) 34 35 api.access_token = json['access_token'] 36 info = api.account_info ··· 41 save_config 42 end 43 44 - def register_oauth_app(instance, scopes) 45 - client = Mastodon::REST::Client.new(base_url: "https://#{instance}") 46 - client.create_app(APP_NAME, 'urn:ietf:wg:oauth:2.0:oob', scopes) 47 end 48 49 - def post_status(text, media_ids = nil, parent_id = nil) 50 instance = @config['handle'].split('@').last 51 api = MastodonAPI.new(instance, @config['access_token']) 52 - api.post_status(text, media_ids, parent_id) 53 end 54 55 def upload_media(data, filename, content_type, alt = nil) ··· 57 api = MastodonAPI.new(instance, @config['access_token']) 58 api.upload_media(data, filename, content_type, alt) 59 end 60 end
··· 1 require 'yaml' 2 require_relative 'mastodon_api' 3 4 class MastodonAccount 5 + APP_NAME = "Tootify" 6 CONFIG_FILE = File.expand_path(File.join(__dir__, '..', 'config', 'mastodon.yml')) 7 + 8 + OAUTH_SCOPES = [ 9 + 'read:accounts', 10 + 'read:statuses', 11 + 'read:search', 12 + 'write:media', 13 + 'write:statuses' 14 + ].join(' ') 15 16 def initialize 17 @config = File.exist?(CONFIG_FILE) ? YAML.load(File.read(CONFIG_FILE)) : {} ··· 25 File.write(CONFIG_FILE, YAML.dump(@config)) 26 end 27 28 + def oauth_login(handle) 29 instance = handle.split('@').last 30 + app_response = register_oauth_app(instance) 31 32 api = MastodonAPI.new(instance) 33 34 + login_url = api.generate_oauth_login_url(app_response['client_id'], OAUTH_SCOPES) 35 + 36 + puts "Open this URL in your web browser and authorize the app:" 37 + puts 38 + puts login_url 39 + puts 40 + puts "Then, enter the received code here:" 41 + puts 42 + 43 + print ">> " 44 + code = STDIN.gets.chomp 45 + 46 + json = api.complete_oauth_login(app_response['client_id'], app_response['client_secret'], code) 47 48 api.access_token = json['access_token'] 49 info = api.account_info ··· 54 save_config 55 end 56 57 + def register_oauth_app(instance) 58 + api = MastodonAPI.new(instance) 59 + api.register_oauth_app(APP_NAME, OAUTH_SCOPES) 60 end 61 62 + def instance_info 63 instance = @config['handle'].split('@').last 64 api = MastodonAPI.new(instance, @config['access_token']) 65 + api.instance_info 66 + end 67 + 68 + def post_status(text, media_ids: nil, parent_id: nil, quoted_status_id: nil) 69 + instance = @config['handle'].split('@').last 70 + api = MastodonAPI.new(instance, @config['access_token']) 71 + api.post_status(text, media_ids: media_ids, parent_id: parent_id, quoted_status_id: quoted_status_id) 72 end 73 74 def upload_media(data, filename, content_type, alt = nil) ··· 76 api = MastodonAPI.new(instance, @config['access_token']) 77 api.upload_media(data, filename, content_type, alt) 78 end 79 + 80 + def search_post_by_url(url) 81 + instance = @config['handle'].split('@').last 82 + api = MastodonAPI.new(instance, @config['access_token']) 83 + api.search_post_by_url(url) 84 + end 85 end
+64 -9
app/mastodon_api.rb
··· 3 require 'uri' 4 5 class MastodonAPI 6 class UnauthenticatedError < StandardError 7 end 8 ··· 22 end 23 end 24 25 attr_accessor :access_token 26 27 def initialize(host, access_token = nil) ··· 30 @access_token = access_token 31 end 32 33 - def oauth_login_with_password(client_id, client_secret, email, password, scopes) 34 params = { 35 client_id: client_id, 36 client_secret: client_secret, 37 - grant_type: 'password', 38 - scope: scopes, 39 - username: email, 40 - password: password 41 } 42 43 post_json("https://#{@host}/oauth/token", params) ··· 48 get_json("/accounts/verify_credentials") 49 end 50 51 def lookup_account(username) 52 json = get_json("/accounts/lookup", { acct: username }) 53 raise UnexpectedResponseError.new unless json.is_a?(Hash) && json['id'].is_a?(String) ··· 58 get_json("/accounts/#{user_id}/statuses", params) 59 end 60 61 - def post_status(text, media_ids = nil, parent_id = nil) 62 params = { status: text } 63 params['media_ids[]'] = media_ids if media_ids 64 params['in_reply_to_id'] = parent_id if parent_id 65 66 post_json("/statuses", params) 67 end ··· 71 headers = { 'Authorization' => "Bearer #{@access_token}" } 72 73 form_data = [ 74 - ['file', data, { :filename => filename, :content_type => content_type }], 75 - ['description', alt.to_s.force_encoding('ASCII-8BIT')] 76 ] 77 78 request = Net::HTTP::Post.new(url, headers) 79 request.set_form(form_data, 'multipart/form-data') 80 ··· 82 http.request(request) 83 end 84 85 - if response.code.to_i / 100 == 2 86 JSON.parse(response.body) 87 else 88 raise APIError.new(response) 89 end 90 end 91 92 def get_json(path, params = {})
··· 3 require 'uri' 4 5 class MastodonAPI 6 + CODE_REDIRECT_URI = 'urn:ietf:wg:oauth:2.0:oob' 7 + 8 class UnauthenticatedError < StandardError 9 end 10 ··· 24 end 25 end 26 27 + MEDIA_CHECK_INTERVAL = 5.0 28 + 29 attr_accessor :access_token 30 31 def initialize(host, access_token = nil) ··· 34 @access_token = access_token 35 end 36 37 + def register_oauth_app(app_name, scopes) 38 + params = { 39 + client_name: app_name, 40 + redirect_uris: CODE_REDIRECT_URI, 41 + scopes: scopes 42 + } 43 + 44 + post_json("https://#{@host}/api/v1/apps", params) 45 + end 46 + 47 + def generate_oauth_login_url(client_id, scopes) 48 + login_url = URI("https://#{@host}/oauth/authorize") 49 + 50 + login_url.query = URI.encode_www_form( 51 + client_id: client_id, 52 + redirect_uri: CODE_REDIRECT_URI, 53 + response_type: 'code', 54 + scope: scopes 55 + ) 56 + 57 + login_url 58 + end 59 + 60 + def complete_oauth_login(client_id, client_secret, code) 61 params = { 62 client_id: client_id, 63 client_secret: client_secret, 64 + redirect_uri: CODE_REDIRECT_URI, 65 + grant_type: 'authorization_code', 66 + code: code 67 } 68 69 post_json("https://#{@host}/oauth/token", params) ··· 74 get_json("/accounts/verify_credentials") 75 end 76 77 + def instance_info 78 + get_json("https://#{@host}/api/v2/instance") 79 + end 80 + 81 def lookup_account(username) 82 json = get_json("/accounts/lookup", { acct: username }) 83 raise UnexpectedResponseError.new unless json.is_a?(Hash) && json['id'].is_a?(String) ··· 88 get_json("/accounts/#{user_id}/statuses", params) 89 end 90 91 + def post_status(text, media_ids: nil, parent_id: nil, quoted_status_id: nil) 92 params = { status: text } 93 params['media_ids[]'] = media_ids if media_ids 94 params['in_reply_to_id'] = parent_id if parent_id 95 + params['quoted_status_id'] = quoted_status_id if quoted_status_id 96 97 post_json("/statuses", params) 98 end ··· 102 headers = { 'Authorization' => "Bearer #{@access_token}" } 103 104 form_data = [ 105 + ['file', data, { :filename => filename, :content_type => content_type }] 106 ] 107 108 + if alt 109 + form_data << ['description', alt.force_encoding('ASCII-8BIT')] 110 + end 111 + 112 request = Net::HTTP::Post.new(url, headers) 113 request.set_form(form_data, 'multipart/form-data') 114 ··· 116 http.request(request) 117 end 118 119 + json = if response.code.to_i / 100 == 2 120 JSON.parse(response.body) 121 else 122 raise APIError.new(response) 123 end 124 + 125 + while json['url'].nil? 126 + sleep MEDIA_CHECK_INTERVAL 127 + json = get_media(json['id']) 128 + end 129 + 130 + json 131 + end 132 + 133 + def get_media(media_id) 134 + get_json("/media/#{media_id}") 135 + end 136 + 137 + def search_post_by_url(url) 138 + json = get_json("https://#{@host}/api/v2/search", { 139 + q: url, 140 + type: 'statuses', 141 + resolve: true 142 + }) 143 + 144 + json['statuses'] && json['statuses'][0] 145 end 146 147 def get_json(path, params = {})
+10
app/post.rb
···
··· 1 + require 'active_record' 2 + 3 + class Post < ActiveRecord::Base 4 + validates_presence_of :mastodon_id, :bluesky_rkey 5 + 6 + validates_length_of :mastodon_id, maximum: 50 7 + validates_length_of :bluesky_rkey, is: 13 8 + 9 + validates_uniqueness_of :bluesky_rkey 10 + end
-23
app/post_history.rb
··· 1 - class PostHistory 2 - HISTORY_FILE = File.expand_path(File.join(__dir__, '..', 'config', 'history.csv')) 3 - 4 - def initialize 5 - if File.exist?(HISTORY_FILE) 6 - @id_map = File.read(HISTORY_FILE).lines.map { |l| l.strip.split(',') }.then { |pairs| Hash[pairs] } 7 - else 8 - @id_map = {} 9 - end 10 - end 11 - 12 - def [](bluesky_rkey) 13 - @id_map[bluesky_rkey] 14 - end 15 - 16 - def add(bluesky_rkey, mastodon_id) 17 - @id_map[bluesky_rkey] = mastodon_id 18 - 19 - File.open(HISTORY_FILE, 'a') do |f| 20 - f.puts("#{bluesky_rkey},#{mastodon_id}") 21 - end 22 - end 23 - end
···
+139 -31
app/tootify.rb
··· 1 require 'io/console' 2 3 require_relative 'bluesky_account' 4 require_relative 'mastodon_account' 5 - require_relative 'post_history' 6 7 class Tootify 8 attr_accessor :check_interval 9 10 def initialize 11 @bluesky = BlueskyAccount.new 12 @mastodon = MastodonAccount.new 13 - @history = PostHistory.new 14 @check_interval = 60 15 end 16 17 - def login_bluesky(handle) 18 handle = handle.gsub(/^@/, '') 19 20 print "App password: " ··· 24 @bluesky.login_with_password(handle, password) 25 end 26 27 - def login_mastodon(handle) 28 - print "Email: " 29 - email = STDIN.gets.chomp 30 - 31 - print "Password: " 32 - password = STDIN.noecho(&:gets).chomp 33 - puts 34 - 35 - @mastodon.oauth_login(handle, email, password) 36 end 37 38 def sync ··· 52 53 next unless repo == @bluesky.did && collection == 'app.bsky.feed.post' 54 55 begin 56 record = @bluesky.fetch_record(repo, collection, rkey) 57 rescue Minisky::ClientErrorResponse => e 58 - puts "Record not found: #{post_uri}" 59 @bluesky.delete_record_at(like_uri) 60 next 61 end ··· 65 prepo = parent_uri.split('/')[2] 66 67 if prepo != @bluesky.did 68 - puts "Skipping reply to someone else" 69 @bluesky.delete_record_at(like_uri) 70 next 71 else ··· 81 82 if reply = record['reply'] 83 parent_uri = reply['parent']['uri'] 84 - prkey = parent_uri.split('/')[4] 85 86 - if parent_id = @history[prkey] 87 - mastodon_parent_id = parent_id 88 else 89 - puts "Skipping reply to a post that wasn't cross-posted" 90 @bluesky.delete_record_at(like_uri) 91 next 92 end 93 end 94 95 response = post_to_mastodon(record, mastodon_parent_id) 96 - p response 97 98 - @history.add(rkey, response['id']) 99 @bluesky.delete_record_at(like_uri) 100 end 101 end ··· 108 end 109 110 def post_to_mastodon(record, mastodon_parent_id = nil) 111 - p record 112 113 text = expand_facets(record) 114 ··· 120 repo, collection, rkey = quote_uri.split('/')[2..4] 121 122 if collection == 'app.bsky.feed.post' 123 - bsky_url = bsky_post_link(repo, rkey) 124 - append_link(text, bsky_url) unless text.include?(bsky_url) 125 end 126 end 127 128 if images = attached_images(record) 129 media_ids = [] 130 131 - images.each do |image| 132 - alt = image['alt'] 133 - cid = image['image']['ref']['$link'] 134 - mime = image['image']['mimeType'] 135 136 - if alt.length > @mastodon.max_alt_length 137 alt = alt[0...@mastodon.max_alt_length - 3] + "(โ€ฆ)" 138 end 139 ··· 142 uploaded_media = @mastodon.upload_media(data, cid, mime, alt) 143 media_ids << uploaded_media['id'] 144 end 145 end 146 147 if tags = record['tags'] 148 text += "\n\n" + tags.map { |t| '#' + t.gsub(' ', '') }.join(' ') 149 end 150 151 - @mastodon.post_status(text, media_ids, mastodon_parent_id) 152 end 153 154 def expand_facets(record) ··· 171 bytes.pack('C*').force_encoding('UTF-8') 172 end 173 174 def link_embed(record) 175 if embed = record['embed'] 176 case embed['$type'] ··· 214 end 215 end 216 217 def append_link(text, link) 218 - text << ' ' unless text.end_with?(' ') 219 - text << link 220 end 221 222 def bsky_post_link(repo, rkey) 223 "https://bsky.app/profile/#{repo}/post/#{rkey}" 224 end 225 end
··· 1 require 'io/console' 2 + require 'yaml' 3 4 require_relative 'bluesky_account' 5 + require_relative 'database' 6 require_relative 'mastodon_account' 7 + require_relative 'post' 8 9 class 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: " ··· 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 ··· 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 ··· 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 ··· 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 ··· 122 end 123 124 def post_to_mastodon(record, mastodon_parent_id = nil) 125 + log(record) 126 127 text = expand_facets(record) 128 ··· 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 ··· 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) ··· 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'] ··· 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 333 end
+10
db/migrate/001_create_posts.rb
···
··· 1 + class CreatePosts < ActiveRecord::Migration[7.2] 2 + def change 3 + create_table :posts do |t| 4 + t.string :bluesky_rkey, null: false 5 + t.string :mastodon_id, null: false 6 + end 7 + 8 + add_index :posts, :bluesky_rkey, unique: true 9 + end 10 + end
+5 -3
tootify
··· 4 require_relative 'app/tootify' 5 6 def run(argv) 7 options, args = argv.partition { |x| x.start_with?('-') } 8 9 app = Tootify.new ··· 36 app = Tootify.new 37 38 if name =~ /\A[^@]+@[^@]+\z/ 39 - app.login_mastodon(name) 40 - elsif name =~ /\A@[^@]+\z/ 41 - app.login_bluesky(name) 42 elsif name.nil? 43 print_help 44 else
··· 4 require_relative 'app/tootify' 5 6 def run(argv) 7 + $stdout.sync = true 8 + 9 options, args = argv.partition { |x| x.start_with?('-') } 10 11 app = Tootify.new ··· 38 app = Tootify.new 39 40 if name =~ /\A[^@]+@[^@]+\z/ 41 + app.login_to_mastodon(name) 42 + elsif name =~ /\A@?[^@]+\z/ 43 + app.login_to_bluesky(name) 44 elsif name.nil? 45 print_help 46 else