Toot toooooooot (Bluesky-Mastodon cross-poster)

Compare changes

Choose any two refs to compare.

+2
.gitignore
··· 1 + .DS_Store 1 2 config/*.yml 3 + db/history.sqlite3*
+4 -2
Gemfile
··· 1 1 source 'https://rubygems.org' 2 2 3 3 gem 'didkit', '~> 0.2' 4 - gem 'mastodon-api', git: 'https://github.com/mastodon/mastodon-api.git' 5 - gem 'minisky', '~> 0.3' 4 + gem 'minisky', '~> 0.5' 6 5 7 6 gem 'io-console', '~> 0.5' 8 7 gem 'json', '~> 2.5' 9 8 gem 'net-http', '~> 0.2' 10 9 gem 'uri', '~> 0.13' 11 10 gem 'yaml', '~> 0.1' 11 + 12 + gem "activerecord", "~> 7.2" 13 + gem "sqlite3", "~> 2.7"
+55 -44
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 1 GEM 12 2 remote: https://rubygems.org/ 13 3 specs: 14 - addressable (2.8.6) 15 - public_suffix (>= 2.0.2, < 6.0) 16 - bigdecimal (3.1.7) 17 - buftok (0.3.0) 18 - didkit (0.2.0) 19 - domain_name (0.6.20240107) 20 - ffi (1.16.3) 21 - ffi-compiler (1.3.2) 22 - ffi (>= 1.15.5) 23 - rake 24 - http (4.4.1) 25 - addressable (~> 2.3) 26 - http-cookie (~> 1.0) 27 - http-form_data (~> 2.2) 28 - http-parser (~> 1.2.0) 29 - http-cookie (1.0.5) 30 - domain_name (~> 0.5) 31 - http-form_data (2.3.0) 32 - http-parser (1.2.3) 33 - ffi-compiler (>= 1.0, < 2.0) 34 - io-console (0.7.2) 35 - json (2.7.1) 36 - minisky (0.3.1) 37 - net-http (0.4.1) 38 - uri 39 - oj (3.16.3) 40 - bigdecimal (>= 3.0) 41 - public_suffix (5.0.4) 42 - rake (13.1.0) 43 - uri (0.13.0) 44 - yaml (0.3.0) 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) 45 53 46 54 PLATFORMS 55 + aarch64-linux 47 56 arm64-darwin-21 48 57 ruby 58 + x86_64-linux 49 59 50 60 DEPENDENCIES 61 + activerecord (~> 7.2) 51 62 didkit (~> 0.2) 52 63 io-console (~> 0.5) 53 64 json (~> 2.5) 54 - mastodon-api! 55 - minisky (~> 0.3) 65 + minisky (~> 0.5) 56 66 net-http (~> 0.2) 67 + sqlite3 (~> 2.7) 57 68 uri (~> 0.13) 58 69 yaml (~> 0.1) 59 70 60 71 BUNDLED WITH 61 - 2.5.6 72 + 2.6.5
+33 -8
README.md
··· 1 1 # Tootify ๐Ÿฆ‹โ†’๐Ÿ˜ 2 2 3 - An experimental Bluesky-to-Mastodon cross-poster 3 + A simple Bluesky-to-Mastodon cross-posting service 4 4 5 5 6 6 ## What does it do 7 7 8 - Tootify will allow you to do a selective one-way sync of Bluesky posts to your Mastodon account. 8 + Tootify allows you to do a selective one-way sync of Bluesky posts to your Mastodon account. 9 9 10 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 11 12 - > [!CAUTION] 13 - > This code is currently pretty unstable, so use at your own risk. 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 14 19 15 20 16 21 ## Installation 17 22 18 - At the moment: 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: 19 26 20 - git clone https://github.com/mackuba/tootify.git 27 + git clone https://tangled.org/mackuba.eu/tootify 21 28 cd tootify 22 29 bundle install 30 + 23 31 24 32 ## Usage 25 33 ··· 38 46 39 47 ./tootify watch 40 48 41 - By default it checks for new skeets every 60 seconds - use the `interval` parameter to customize the interval: 49 + By default it checks for new skeets every 60 seconds โ€“ use the `interval` parameter to customize the interval: 42 50 43 51 ./tootify watch --interval=15 44 52 45 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 + 46 69 ## Credits 47 70 48 - Copyright ยฉ 2024 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/mackuba.eu)). 71 + Copyright ยฉ 2025 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr)). 49 72 50 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 ๐Ÿ˜Ž
+10 -9
app/bluesky_account.rb
··· 17 17 exit 1 18 18 end 19 19 20 - pds = did.get_document.pds_endpoint.gsub('https://', '') 21 - 22 - @sky.host = pds 20 + @sky.host = did.document.pds_host 23 21 @sky.user.id = handle 24 22 @sky.user.pass = password 25 23 24 + @sky.log_in 25 + end 26 + 27 + def log_in 26 28 @sky.log_in 27 29 end 28 30 ··· 40 42 @sky.get_request('com.atproto.repo.getRecord', { repo: repo, collection: collection, rkey: rkey }) 41 43 end 42 44 45 + def fetch_blob(cid) 46 + @sky.get_request('com.atproto.sync.getBlob', { did: @sky.user.did, cid: cid }) 47 + end 48 + 43 49 def delete_record_at(uri) 44 50 repo, collection, rkey = uri.split('/')[2..4] 45 - 46 - begin 47 - @sky.post_request('com.atproto.repo.deleteRecord', { repo: repo, collection: collection, rkey: rkey }) 48 - rescue JSON::ParserError 49 - # todo 50 - end 51 + @sky.post_request('com.atproto.repo.deleteRecord', { repo: repo, collection: collection, rkey: rkey }) 51 52 end 52 53 end
+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
+51 -16
app/mastodon_account.rb
··· 1 - require 'mastodon' 2 1 require 'yaml' 3 - 4 2 require_relative 'mastodon_api' 5 3 6 4 class MastodonAccount 7 - APP_NAME = "tootify" 5 + APP_NAME = "Tootify" 8 6 CONFIG_FILE = File.expand_path(File.join(__dir__, '..', 'config', 'mastodon.yml')) 9 - OAUTH_SCOPES = 'read:accounts read:statuses write:media write:statuses' 7 + 8 + OAUTH_SCOPES = [ 9 + 'read:accounts', 10 + 'read:statuses', 11 + 'read:search', 12 + 'write:media', 13 + 'write:statuses' 14 + ].join(' ') 10 15 11 16 def initialize 12 17 @config = File.exist?(CONFIG_FILE) ? YAML.load(File.read(CONFIG_FILE)) : {} 18 + end 19 + 20 + def max_alt_length 21 + 1500 13 22 end 14 23 15 24 def save_config 16 25 File.write(CONFIG_FILE, YAML.dump(@config)) 17 26 end 18 27 19 - def oauth_login(handle, email, password) 28 + def oauth_login(handle) 20 29 instance = handle.split('@').last 21 - app_response = register_oauth_app(instance, OAUTH_SCOPES) 30 + app_response = register_oauth_app(instance) 22 31 23 32 api = MastodonAPI.new(instance) 24 33 25 - json = api.oauth_login_with_password( 26 - app_response.client_id, 27 - app_response.client_secret, 28 - email, password, OAUTH_SCOPES 29 - ) 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) 30 47 31 48 api.access_token = json['access_token'] 32 49 info = api.account_info ··· 37 54 save_config 38 55 end 39 56 40 - def register_oauth_app(instance, scopes) 41 - client = Mastodon::REST::Client.new(base_url: "https://#{instance}") 42 - client.create_app(APP_NAME, 'urn:ietf:wg:oauth:2.0:oob', scopes) 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) 43 72 end 44 73 45 - def post_status(text) 74 + def upload_media(data, filename, content_type, alt = nil) 46 75 instance = @config['handle'].split('@').last 47 76 api = MastodonAPI.new(instance, @config['access_token']) 48 - api.post_status(text) 77 + api.upload_media(data, filename, content_type, alt) 49 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 50 85 end
+88 -8
app/mastodon_api.rb
··· 3 3 require 'uri' 4 4 5 5 class MastodonAPI 6 + CODE_REDIRECT_URI = 'urn:ietf:wg:oauth:2.0:oob' 7 + 6 8 class UnauthenticatedError < StandardError 7 9 end 8 10 ··· 22 24 end 23 25 end 24 26 27 + MEDIA_CHECK_INTERVAL = 5.0 28 + 25 29 attr_accessor :access_token 26 30 27 31 def initialize(host, access_token = nil) ··· 30 34 @access_token = access_token 31 35 end 32 36 33 - def oauth_login_with_password(client_id, client_secret, email, password, scopes) 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) 34 61 params = { 35 62 client_id: client_id, 36 63 client_secret: client_secret, 37 - grant_type: 'password', 38 - scope: scopes, 39 - username: email, 40 - password: password 64 + redirect_uri: CODE_REDIRECT_URI, 65 + grant_type: 'authorization_code', 66 + code: code 41 67 } 42 68 43 69 post_json("https://#{@host}/oauth/token", params) ··· 48 74 get_json("/accounts/verify_credentials") 49 75 end 50 76 77 + def instance_info 78 + get_json("https://#{@host}/api/v2/instance") 79 + end 80 + 51 81 def lookup_account(username) 52 82 json = get_json("/accounts/lookup", { acct: username }) 53 83 raise UnexpectedResponseError.new unless json.is_a?(Hash) && json['id'].is_a?(String) ··· 58 88 get_json("/accounts/#{user_id}/statuses", params) 59 89 end 60 90 61 - def post_status(text) 62 - post_json("/statuses", { 63 - status: text 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 99 + 100 + def upload_media(data, filename, content_type, alt = nil) 101 + url = URI("https://#{@host}/api/v2/media") 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 + 115 + response = Net::HTTP.start(url.hostname, url.port, :use_ssl => true) do |http| 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 64 142 }) 143 + 144 + json['statuses'] && json['statuses'][0] 65 145 end 66 146 67 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
+221 -21
app/tootify.rb
··· 1 1 require 'io/console' 2 + require 'yaml' 2 3 3 4 require_relative 'bluesky_account' 5 + require_relative 'database' 4 6 require_relative 'mastodon_account' 7 + require_relative 'post' 5 8 6 9 class Tootify 10 + CONFIG_FILE = File.expand_path(File.join(__dir__, '..', 'config', 'tootify.yml')) 11 + 7 12 attr_accessor :check_interval 8 13 9 14 def initialize 10 15 @bluesky = BlueskyAccount.new 11 16 @mastodon = MastodonAccount.new 17 + @config = load_config 12 18 @check_interval = 60 19 + 20 + Database.init 13 21 end 14 22 15 - def login_bluesky(handle) 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) 16 32 handle = handle.gsub(/^@/, '') 17 33 18 34 print "App password: " ··· 22 38 @bluesky.login_with_password(handle, password) 23 39 end 24 40 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) 41 + def login_to_mastodon(handle) 42 + @mastodon.oauth_login(handle) 34 43 end 35 44 36 45 def sync 37 - likes = @bluesky.fetch_likes 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 = [] 38 54 39 55 likes.each do |r| 40 56 like_uri = r['uri'] ··· 43 59 44 60 next unless repo == @bluesky.did && collection == 'app.bsky.feed.post' 45 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 + 46 68 begin 47 69 record = @bluesky.fetch_record(repo, collection, rkey) 48 70 rescue Minisky::ClientErrorResponse => e 49 - puts "Record not found: #{post_uri}" 71 + log "Record not found: #{post_uri}" 50 72 @bluesky.delete_record_at(like_uri) 51 73 next 52 74 end 53 75 54 - post_to_mastodon(record['value']) 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']) 55 112 56 113 @bluesky.delete_record_at(like_uri) 57 114 end ··· 64 121 end 65 122 end 66 123 67 - def post_to_mastodon(record) 68 - p record 124 + def post_to_mastodon(record, mastodon_parent_id = nil) 125 + log(record) 69 126 70 127 text = expand_facets(record) 71 128 ··· 77 134 repo, collection, rkey = quote_uri.split('/')[2..4] 78 135 79 136 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) 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) 82 170 end 83 171 end 84 172 85 - p @mastodon.post_status(text) 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'] 86 218 end 87 219 88 220 def expand_facets(record) ··· 103 235 end 104 236 105 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/') } 106 252 end 107 253 108 254 def link_embed(record) 109 - record['embed'] && record['embed']['external'] && record['embed']['external']['uri'] 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 110 265 end 111 266 112 267 def quoted_post(record) ··· 122 277 end 123 278 end 124 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 + 125 314 def append_link(text, link) 126 - text << ' ' unless text.end_with?(' ') 127 - 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 128 323 end 129 324 130 325 def bsky_post_link(repo, rkey) 131 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}" 132 332 end 133 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
+6 -4
tootify
··· 4 4 require_relative 'app/tootify' 5 5 6 6 def run(argv) 7 + $stdout.sync = true 8 + 7 9 options, args = argv.partition { |x| x.start_with?('-') } 8 10 9 11 app = Tootify.new ··· 28 30 29 31 def print_help 30 32 puts "Usage: #{$PROGRAM_NAME} login mastodon@account | login @bluesky" 31 - puts " #{$PROGRAM_NAME} check" 33 + puts " #{$PROGRAM_NAME} check | watch" 32 34 exit 1 33 35 end 34 36 ··· 36 38 app = Tootify.new 37 39 38 40 if name =~ /\A[^@]+@[^@]+\z/ 39 - app.login_mastodon(name) 40 - elsif name =~ /\A@[^@]+\z/ 41 - app.login_bluesky(name) 41 + app.login_to_mastodon(name) 42 + elsif name =~ /\A@?[^@]+\z/ 43 + app.login_to_bluesky(name) 42 44 elsif name.nil? 43 45 print_help 44 46 else