Toot toooooooot (Bluesky-Mastodon cross-poster)

logging in to mastodon

+1
.gitignore
···
··· 1 + config/*.yml
+9
Gemfile
···
··· 1 + source 'https://rubygems.org' 2 + 3 + gem 'mastodon-api', git: 'https://github.com/mastodon/mastodon-api.git' 4 + 5 + gem 'io-console', '~> 0.5' 6 + gem 'json', '~> 2.5' 7 + gem 'net-http', '~> 0.2' 8 + gem 'uri', '~> 0.13' 9 + gem 'yaml', '~> 0.1'
+57
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.6) 15 + public_suffix (>= 2.0.2, < 6.0) 16 + bigdecimal (3.1.7) 17 + buftok (0.3.0) 18 + domain_name (0.6.20240107) 19 + ffi (1.16.3) 20 + ffi-compiler (1.3.2) 21 + ffi (>= 1.15.5) 22 + rake 23 + http (4.4.1) 24 + addressable (~> 2.3) 25 + http-cookie (~> 1.0) 26 + http-form_data (~> 2.2) 27 + http-parser (~> 1.2.0) 28 + http-cookie (1.0.5) 29 + domain_name (~> 0.5) 30 + http-form_data (2.3.0) 31 + http-parser (1.2.3) 32 + ffi-compiler (>= 1.0, < 2.0) 33 + io-console (0.7.2) 34 + json (2.7.1) 35 + net-http (0.4.1) 36 + uri 37 + oj (3.16.3) 38 + bigdecimal (>= 3.0) 39 + public_suffix (5.0.4) 40 + rake (13.1.0) 41 + uri (0.13.0) 42 + yaml (0.3.0) 43 + 44 + PLATFORMS 45 + arm64-darwin-21 46 + ruby 47 + 48 + DEPENDENCIES 49 + io-console (~> 0.5) 50 + json (~> 2.5) 51 + mastodon-api! 52 + net-http (~> 0.2) 53 + uri (~> 0.13) 54 + yaml (~> 0.1) 55 + 56 + BUNDLED WITH 57 + 2.5.6
+44
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)) : {} 13 + end 14 + 15 + def save_config 16 + File.write(CONFIG_FILE, YAML.dump(@config)) 17 + end 18 + 19 + def oauth_login(handle, email, password) 20 + instance = handle.split('@').last 21 + app_response = register_oauth_app(instance, OAUTH_SCOPES) 22 + 23 + api = MastodonAPI.new(instance) 24 + 25 + json = api.oauth_login_with_password( 26 + app_response.client_id, 27 + app_response.client_secret, 28 + email, password, OAUTH_SCOPES 29 + ) 30 + 31 + api.access_token = json['access_token'] 32 + info = api.account_info 33 + 34 + @config['handle'] = handle 35 + @config['access_token'] = api.access_token 36 + @config['user_id'] = info['id'] 37 + save_config 38 + end 39 + 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) 43 + end 44 + end
+88
app/mastodon_api.rb
···
··· 1 + require 'json' 2 + require 'net/http' 3 + require 'uri' 4 + 5 + class MastodonAPI 6 + class UnauthenticatedError < StandardError 7 + end 8 + 9 + class UnexpectedResponseError < StandardError 10 + end 11 + 12 + class APIError < StandardError 13 + attr_reader :response 14 + 15 + def initialize(response) 16 + @response = response 17 + super("APIError #{response.code}: #{response.body}") 18 + end 19 + 20 + def status 21 + response.code.to_i 22 + end 23 + end 24 + 25 + attr_accessor :access_token 26 + 27 + def initialize(host, access_token = nil) 28 + @host = host 29 + @root = "https://#{@host}/api/v1" 30 + @access_token = access_token 31 + end 32 + 33 + def oauth_login_with_password(client_id, client_secret, email, password, scopes) 34 + url = URI("https://#{@host}/oauth/token") 35 + 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 + response = Net::HTTP.post_form(url, params) 46 + status = response.code.to_i 47 + 48 + if status / 100 == 2 49 + JSON.parse(response.body) 50 + else 51 + raise APIError.new(response) 52 + end 53 + end 54 + 55 + def account_info 56 + raise UnauthenticatedError.new unless @access_token 57 + get_json("/accounts/verify_credentials") 58 + end 59 + 60 + def lookup_account(username) 61 + json = get_json("/accounts/lookup", { acct: username }) 62 + raise UnexpectedResponseError.new unless json.is_a?(Hash) && json['id'].is_a?(String) 63 + json 64 + end 65 + 66 + def account_statuses(user_id, params = {}) 67 + get_json("/accounts/#{user_id}/statuses", params) 68 + end 69 + 70 + def get_json(path, params = {}) 71 + url = URI(path.start_with?('https://') ? path : @root + path) 72 + url.query = URI.encode_www_form(params) if params 73 + 74 + headers = {} 75 + headers['Authorization'] = "Bearer #{@access_token}" if @access_token 76 + 77 + response = Net::HTTP.get_response(url, headers) 78 + status = response.code.to_i 79 + 80 + if status / 100 == 2 81 + JSON.parse(response.body) 82 + elsif status / 100 == 3 83 + get_json(response['Location']) 84 + else 85 + raise APIError.new(response) 86 + end 87 + end 88 + end
+22
app/tootify.rb
···
··· 1 + require 'io/console' 2 + require_relative 'mastodon_account' 3 + 4 + class Tootify 5 + def initialize 6 + @mastodon = MastodonAccount.new 7 + end 8 + 9 + def login_bluesky 10 + end 11 + 12 + def login_mastodon(handle) 13 + print "Email: " 14 + email = STDIN.gets.chomp 15 + 16 + print "Password: " 17 + password = STDIN.noecho(&:gets).chomp 18 + puts 19 + 20 + @mastodon.oauth_login(handle, email, password) 21 + end 22 + end
+39
tootify
···
··· 1 + #!/usr/bin/env ruby 2 + 3 + require 'bundler/setup' 4 + require_relative 'app/tootify' 5 + 6 + $app = Tootify.new 7 + 8 + def run(argv) 9 + options, args = argv.partition { |x| x.start_with?('-') } 10 + 11 + case args.first 12 + when 'login' 13 + login(args[1]) 14 + when 'check' 15 + else 16 + print_help 17 + end 18 + end 19 + 20 + def print_help 21 + puts "Usage: #{$PROGRAM_NAME} login mastodon@account | login @bluesky" 22 + puts " #{$PROGRAM_NAME} check" 23 + exit 1 24 + end 25 + 26 + def login(name) 27 + if name =~ /\A[^@]+@[^@]+\z/ 28 + $app.login_mastodon(name) 29 + elsif name =~ /\A@[^@]+\z/ 30 + $app.login_bluesky(name) 31 + elsif name.nil? 32 + print_help 33 + else 34 + puts "Invalid handle: #{name.inspect}" 35 + exit 1 36 + end 37 + end 38 + 39 + run(ARGV)