Toot toooooooot (Bluesky-Mastodon cross-poster)

Compare changes

Choose any two refs to compare.

+1 -1
.gitignore
··· 1 .DS_Store 2 config/*.yml 3 - db/history.sqlite3
··· 1 .DS_Store 2 config/*.yml 3 + db/history.sqlite3*
+27 -25
Gemfile.lock
··· 1 GEM 2 remote: https://rubygems.org/ 3 specs: 4 - activemodel (7.2.2.1) 5 - activesupport (= 7.2.2.1) 6 - activerecord (7.2.2.1) 7 - activemodel (= 7.2.2.1) 8 - activesupport (= 7.2.2.1) 9 timeout (>= 0.4.0) 10 - activesupport (7.2.2.1) 11 base64 12 benchmark (>= 0.3) 13 bigdecimal ··· 19 minitest (>= 5.1) 20 securerandom (>= 0.3) 21 tzinfo (~> 2.0, >= 2.0.5) 22 - base64 (0.2.0) 23 - benchmark (0.4.1) 24 - bigdecimal (3.2.2) 25 - concurrent-ruby (1.3.5) 26 - connection_pool (2.5.3) 27 - didkit (0.2.3) 28 drb (2.2.3) 29 - i18n (1.14.7) 30 concurrent-ruby (~> 1.0) 31 - io-console (0.7.2) 32 - json (2.10.2) 33 logger (1.7.0) 34 mini_portile2 (2.8.9) 35 minisky (0.5.0) 36 base64 (~> 0.1) 37 - minitest (5.25.5) 38 - net-http (0.4.1) 39 - uri 40 securerandom (0.4.1) 41 - sqlite3 (2.7.1) 42 mini_portile2 (~> 2.8.0) 43 - sqlite3 (2.7.1-aarch64-linux-gnu) 44 - sqlite3 (2.7.1-arm64-darwin) 45 - sqlite3 (2.7.1-x86_64-linux-gnu) 46 - timeout (0.4.3) 47 tzinfo (2.0.6) 48 concurrent-ruby (~> 1.0) 49 - uri (0.13.2) 50 - yaml (0.3.0) 51 52 PLATFORMS 53 aarch64-linux
··· 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 ··· 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
+7 -3
README.md
··· 20 21 ## Installation 22 23 - At the moment: 24 25 - git clone https://github.com/mackuba/tootify.git 26 cd tootify 27 bundle install 28 ··· 66 67 ## Credits 68 69 - Copyright ยฉ 2025 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/mackuba.eu)). 70 71 The code is available under the terms of the [zlib license](https://choosealicense.com/licenses/zlib/) (permissive, similar to MIT).
··· 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 ··· 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
+22 -3
app/mastodon_account.rb
··· 4 class MastodonAccount 5 APP_NAME = "Tootify" 6 CONFIG_FILE = File.expand_path(File.join(__dir__, '..', 'config', 'mastodon.yml')) 7 - OAUTH_SCOPES = 'read:accounts read:statuses write:media write:statuses' 8 9 def initialize 10 @config = File.exist?(CONFIG_FILE) ? YAML.load(File.read(CONFIG_FILE)) : {} ··· 52 api.register_oauth_app(APP_NAME, OAUTH_SCOPES) 53 end 54 55 - def post_status(text, media_ids = nil, parent_id = nil) 56 instance = @config['handle'].split('@').last 57 api = MastodonAPI.new(instance, @config['access_token']) 58 - api.post_status(text, media_ids, parent_id) 59 end 60 61 def upload_media(data, filename, content_type, alt = nil) ··· 63 api = MastodonAPI.new(instance, @config['access_token']) 64 api.upload_media(data, filename, content_type, alt) 65 end 66 end
··· 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)) : {} ··· 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
+16 -1
app/mastodon_api.rb
··· 74 get_json("/accounts/verify_credentials") 75 end 76 77 def lookup_account(username) 78 json = get_json("/accounts/lookup", { acct: username }) 79 raise UnexpectedResponseError.new unless json.is_a?(Hash) && json['id'].is_a?(String) ··· 84 get_json("/accounts/#{user_id}/statuses", params) 85 end 86 87 - def post_status(text, media_ids = nil, parent_id = nil) 88 params = { status: text } 89 params['media_ids[]'] = media_ids if media_ids 90 params['in_reply_to_id'] = parent_id if parent_id 91 92 post_json("/statuses", params) 93 end ··· 127 128 def get_media(media_id) 129 get_json("/media/#{media_id}") 130 end 131 132 def get_json(path, 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 ··· 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 = {})
+19 -4
app/tootify.rb
··· 135 136 if collection == 'app.bsky.feed.post' 137 link_to_append = bsky_post_link(repo, rkey) 138 139 - if @config['extract_link_from_quotes'] 140 quoted_record = fetch_record_by_at_uri(quote_uri) 141 142 quote_link = link_embed(quoted_record) 143 144 if quote_link.nil? ··· 151 end 152 end 153 154 - append_link(text, link_to_append) unless text.include?(link_to_append) 155 end 156 end 157 ··· 191 text += "\n\n" + tags.map { |t| '#' + t.gsub(' ', '') }.join(' ') 192 end 193 194 - @mastodon.post_status(text, media_ids, mastodon_parent_id) 195 end 196 197 def fetch_record_by_at_uri(quote_uri) 198 repo, collection, rkey = quote_uri.split('/')[2..4] 199 - pds = DID.new(repo).get_document.pds_endpoint 200 sky = Minisky.new(pds, nil) 201 resp = sky.get_request('com.atproto.repo.getRecord', { repo: repo, collection: collection, rkey: rkey }) 202 resp['value']
··· 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? ··· 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 ··· 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']
+1 -1
tootify
··· 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
··· 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