Toot toooooooot (Bluesky-Mastodon cross-poster)

Compare changes

Choose any two refs to compare.

+1
.gitignore
··· 1 1 .DS_Store 2 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.4' 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"
+52 -45
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 - 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.16.3) 22 - ffi-compiler (1.3.2) 23 - ffi (>= 1.15.5) 24 - rake 25 - http (4.4.1) 26 - addressable (~> 2.3) 27 - http-cookie (~> 1.0) 28 - http-form_data (~> 2.2) 29 - http-parser (~> 1.2.0) 30 - http-cookie (1.0.5) 31 - domain_name (~> 0.5) 32 - http-form_data (2.3.0) 33 - http-parser (1.2.3) 34 - ffi-compiler (>= 1.0, < 2.0) 35 - io-console (0.7.2) 36 - json (2.10.1) 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) 37 35 minisky (0.5.0) 38 36 base64 (~> 0.1) 39 - net-http (0.4.1) 40 - uri 41 - oj (3.16.9) 42 - bigdecimal (>= 3.0) 43 - ostruct (>= 0.2) 44 - ostruct (0.6.1) 45 - public_suffix (5.0.4) 46 - rake (13.2.1) 47 - uri (0.13.0) 48 - yaml (0.3.0) 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) 49 53 50 54 PLATFORMS 55 + aarch64-linux 51 56 arm64-darwin-21 52 57 ruby 58 + x86_64-linux 53 59 54 60 DEPENDENCIES 61 + activerecord (~> 7.2) 55 62 didkit (~> 0.2) 56 63 io-console (~> 0.5) 57 64 json (~> 2.5) 58 - mastodon-api! 59 - minisky (~> 0.4) 65 + minisky (~> 0.5) 60 66 net-http (~> 0.2) 67 + sqlite3 (~> 2.7) 61 68 uri (~> 0.13) 62 69 yaml (~> 0.1) 63 70
+33 -7
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 - 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. 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 13 19 14 20 15 21 ## Installation 16 22 17 - 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: 18 26 19 - git clone https://github.com/mackuba/tootify.git 27 + git clone https://tangled.org/mackuba.eu/tootify 20 28 cd tootify 21 29 bundle install 30 + 22 31 23 32 ## Usage 24 33 ··· 37 46 38 47 ./tootify watch 39 48 40 - 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: 41 50 42 51 ./tootify watch --interval=15 43 52 44 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 + 45 69 ## Credits 46 70 47 - 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)). 48 72 49 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 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
+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 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)) : {} ··· 20 25 File.write(CONFIG_FILE, YAML.dump(@config)) 21 26 end 22 27 23 - def oauth_login(handle, email, password) 28 + def oauth_login(handle) 24 29 instance = handle.split('@').last 25 - app_response = register_oauth_app(instance, OAUTH_SCOPES) 30 + app_response = register_oauth_app(instance) 26 31 27 32 api = MastodonAPI.new(instance) 28 33 29 - json = api.oauth_login_with_password( 30 - app_response.client_id, 31 - app_response.client_secret, 32 - email, password, OAUTH_SCOPES 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) 34 47 35 48 api.access_token = json['access_token'] 36 49 info = api.account_info ··· 41 54 save_config 42 55 end 43 56 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) 57 + def register_oauth_app(instance) 58 + api = MastodonAPI.new(instance) 59 + api.register_oauth_app(APP_NAME, OAUTH_SCOPES) 47 60 end 48 61 49 - def post_status(text, media_ids = nil) 62 + def instance_info 50 63 instance = @config['handle'].split('@').last 51 64 api = MastodonAPI.new(instance, @config['access_token']) 52 - api.post_status(text, media_ids) 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) 53 72 end 54 73 55 74 def upload_media(data, filename, content_type, alt = nil) ··· 57 76 api = MastodonAPI.new(instance, @config['access_token']) 58 77 api.upload_media(data, filename, content_type, alt) 59 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 60 85 end
+65 -9
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, media_ids = nil) 91 + def post_status(text, media_ids: nil, parent_id: nil, quoted_status_id: nil) 62 92 params = { status: text } 63 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 64 96 65 97 post_json("/statuses", params) 66 98 end ··· 70 102 headers = { 'Authorization' => "Bearer #{@access_token}" } 71 103 72 104 form_data = [ 73 - ['file', data, { :filename => filename, :content_type => content_type }], 74 - ['description', alt.to_s.force_encoding('ASCII-8BIT')] 105 + ['file', data, { :filename => filename, :content_type => content_type }] 75 106 ] 76 107 108 + if alt 109 + form_data << ['description', alt.force_encoding('ASCII-8BIT')] 110 + end 111 + 77 112 request = Net::HTTP::Post.new(url, headers) 78 113 request.set_form(form_data, 'multipart/form-data') 79 114 ··· 81 116 http.request(request) 82 117 end 83 118 84 - if response.code.to_i / 100 == 2 119 + json = if response.code.to_i / 100 == 2 85 120 JSON.parse(response.body) 86 121 else 87 122 raise APIError.new(response) 88 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] 89 145 end 90 146 91 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
+170 -31
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 ··· 50 59 51 60 next unless repo == @bluesky.did && collection == 'app.bsky.feed.post' 52 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 + 53 68 begin 54 69 record = @bluesky.fetch_record(repo, collection, rkey) 55 70 rescue Minisky::ClientErrorResponse => e 56 - puts "Record not found: #{post_uri}" 71 + log "Record not found: #{post_uri}" 57 72 @bluesky.delete_record_at(like_uri) 58 73 next 59 74 end 60 75 61 - if record['value']['reply'] 62 - puts "Skipping reply" 63 - @bluesky.delete_record_at(like_uri) 64 - next 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 65 87 end 66 88 67 - records << [record['value'], like_uri] 89 + records << [record['value'], rkey, like_uri] 68 90 end 69 91 70 - records.sort_by { |x| x[0]['createdAt'] }.each do |record, like_uri| 71 - post_to_mastodon(record) 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']) 112 + 72 113 @bluesky.delete_record_at(like_uri) 73 114 end 74 115 end ··· 80 121 end 81 122 end 82 123 83 - def post_to_mastodon(record) 84 - p record 124 + def post_to_mastodon(record, mastodon_parent_id = nil) 125 + log(record) 85 126 86 127 text = expand_facets(record) 87 128 ··· 93 134 repo, collection, rkey = quote_uri.split('/')[2..4] 94 135 95 136 if collection == 'app.bsky.feed.post' 96 - bsky_url = bsky_post_link(repo, rkey) 97 - 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) 98 170 end 99 171 end 100 172 101 173 if images = attached_images(record) 102 174 media_ids = [] 103 175 104 - images.each do |image| 105 - alt = image['alt'] 106 - cid = image['image']['ref']['$link'] 107 - mime = image['image']['mimeType'] 176 + images.each do |embed| 177 + alt = embed['alt'] 178 + cid = embed['image']['ref']['$link'] 179 + mime = embed['image']['mimeType'] 108 180 109 - if alt.length > @mastodon.max_alt_length 181 + if alt && alt.length > @mastodon.max_alt_length 110 182 alt = alt[0...@mastodon.max_alt_length - 3] + "(โ€ฆ)" 111 183 end 112 184 ··· 115 187 uploaded_media = @mastodon.upload_media(data, cid, mime, alt) 116 188 media_ids << uploaded_media['id'] 117 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']] 118 203 end 119 204 120 205 if tags = record['tags'] 121 206 text += "\n\n" + tags.map { |t| '#' + t.gsub(' ', '') }.join(' ') 122 207 end 123 208 124 - p @mastodon.post_status(text, media_ids) 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'] 125 218 end 126 219 127 220 def expand_facets(record) ··· 144 237 bytes.pack('C*').force_encoding('UTF-8') 145 238 end 146 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 + 147 254 def link_embed(record) 148 255 if embed = record['embed'] 149 256 case embed['$type'] ··· 176 283 when 'app.bsky.embed.images' 177 284 embed['images'] 178 285 when 'app.bsky.embed.recordWithMedia' 179 - embed['media']['images'] 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 180 308 else 181 309 nil 182 310 end ··· 184 312 end 185 313 186 314 def append_link(text, link) 187 - text << ' ' unless text.end_with?(' ') 188 - 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 189 323 end 190 324 191 325 def bsky_post_link(repo, rkey) 192 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}" 193 332 end 194 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 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 ··· 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