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*
+3 -1
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.5' 6 7 gem 'io-console', '~> 0.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' ··· 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"
+49 -47
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.5) 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
+9 -4
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 ··· 55 56 * `bluesky.yml` โ€“ created when you log in, stores Bluesky user ID/password and access tokens 57 * `mastodon.yml` โ€“ created when you log in, stores Mastodon user ID/password and access tokens 58 - * `history.csv` โ€“ stores a mapping between Bluesky and Mastodon post IDs; used for reply references in threads 59 * `tootify.yml` - optional additional configuration 60 61 The config in `tootify.yml` currently supports one option: 62 63 - `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) 64 65 66 ## Credits 67 68 - Copyright ยฉ 2025 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/mackuba.eu)). 69 70 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 ··· 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
+45 -6
app/mastodon_api.rb
··· 3 require 'uri' 4 5 class MastodonAPI 6 class UnauthenticatedError < StandardError 7 end 8 ··· 32 @access_token = access_token 33 end 34 35 - def oauth_login_with_password(client_id, client_secret, email, password, scopes) 36 params = { 37 client_id: client_id, 38 client_secret: client_secret, 39 - grant_type: 'password', 40 - scope: scopes, 41 - username: email, 42 - password: password 43 } 44 45 post_json("https://#{@host}/oauth/token", params) ··· 50 get_json("/accounts/verify_credentials") 51 end 52 53 def lookup_account(username) 54 json = get_json("/accounts/lookup", { acct: username }) 55 raise UnexpectedResponseError.new unless json.is_a?(Hash) && json['id'].is_a?(String) ··· 60 get_json("/accounts/#{user_id}/statuses", params) 61 end 62 63 - def post_status(text, media_ids = nil, parent_id = nil) 64 params = { status: text } 65 params['media_ids[]'] = media_ids if media_ids 66 params['in_reply_to_id'] = parent_id if parent_id 67 68 post_json("/statuses", params) 69 end ··· 103 104 def get_media(media_id) 105 get_json("/media/#{media_id}") 106 end 107 108 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 ··· 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 ··· 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
···
+71 -28
app/tootify.rb
··· 2 require 'yaml' 3 4 require_relative 'bluesky_account' 5 require_relative 'mastodon_account' 6 - require_relative 'post_history' 7 8 class Tootify 9 CONFIG_FILE = File.expand_path(File.join(__dir__, '..', 'config', 'tootify.yml')) ··· 13 def initialize 14 @bluesky = BlueskyAccount.new 15 @mastodon = MastodonAccount.new 16 - @history = PostHistory.new 17 @config = load_config 18 @check_interval = 60 19 end 20 21 def load_config ··· 26 end 27 end 28 29 - def login_bluesky(handle) 30 handle = handle.gsub(/^@/, '') 31 32 print "App password: " ··· 36 @bluesky.login_with_password(handle, password) 37 end 38 39 - def login_mastodon(handle) 40 - print "Email: " 41 - email = STDIN.gets.chomp 42 - 43 - print "Password: " 44 - password = STDIN.noecho(&:gets).chomp 45 - puts 46 - 47 - @mastodon.oauth_login(handle, email, password) 48 end 49 50 def sync ··· 63 repo, collection, rkey = post_uri.split('/')[2..4] 64 65 next unless repo == @bluesky.did && collection == 'app.bsky.feed.post' 66 67 begin 68 record = @bluesky.fetch_record(repo, collection, rkey) 69 rescue Minisky::ClientErrorResponse => e 70 - puts "Record not found: #{post_uri}" 71 @bluesky.delete_record_at(like_uri) 72 next 73 end ··· 77 prepo = parent_uri.split('/')[2] 78 79 if prepo != @bluesky.did 80 - puts "Skipping reply to someone else" 81 @bluesky.delete_record_at(like_uri) 82 next 83 else ··· 93 94 if reply = record['reply'] 95 parent_uri = reply['parent']['uri'] 96 - prkey = parent_uri.split('/')[4] 97 98 - if parent_id = @history[prkey] 99 - mastodon_parent_id = parent_id 100 else 101 - puts "Skipping reply to a post that wasn't cross-posted" 102 @bluesky.delete_record_at(like_uri) 103 next 104 end 105 end 106 107 response = post_to_mastodon(record, mastodon_parent_id) 108 - p response 109 110 - @history.add(rkey, response['id']) 111 @bluesky.delete_record_at(like_uri) 112 end 113 end ··· 120 end 121 122 def post_to_mastodon(record, mastodon_parent_id = nil) 123 - p record 124 125 text = expand_facets(record) 126 ··· 133 134 if collection == 'app.bsky.feed.post' 135 link_to_append = bsky_post_link(repo, rkey) 136 137 - if @config['extract_link_from_quotes'] 138 quoted_record = fetch_record_by_at_uri(quote_uri) 139 140 - if link_from_quote = link_embed(quoted_record) 141 - link_to_append = link_from_quote 142 end 143 end 144 145 - append_link(text, link_to_append) unless text.include?(link_to_append) 146 end 147 end 148 ··· 182 text += "\n\n" + tags.map { |t| '#' + t.gsub(' ', '') }.join(' ') 183 end 184 185 - @mastodon.post_status(text, media_ids, mastodon_parent_id) 186 end 187 188 def fetch_record_by_at_uri(quote_uri) 189 repo, collection, rkey = quote_uri.split('/')[2..4] 190 - pds = DID.new(repo).get_document.pds_endpoint 191 sky = Minisky.new(pds, nil) 192 resp = sky.get_request('com.atproto.repo.getRecord', { repo: repo, collection: collection, rkey: rkey }) 193 resp['value'] ··· 211 end 212 213 bytes.pack('C*').force_encoding('UTF-8') 214 end 215 216 def link_embed(record) ··· 274 end 275 276 def append_link(text, link) 277 - if link =~ /^https:\/\/bsky\.app\/profile\/.+\/post\/.+/ 278 text << "\n" unless text.end_with?("\n") 279 text << "\n" 280 text << "RE: " + link ··· 286 287 def bsky_post_link(repo, rkey) 288 "https://bsky.app/profile/#{repo}/post/#{rkey}" 289 end 290 end
··· 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')) ··· 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 ··· 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 ··· 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 ··· 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 ··· 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 ··· 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'] ··· 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) ··· 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 ··· 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