Toot toooooooot (Bluesky-Mastodon cross-poster)
1require 'json' 2require 'net/http' 3require 'uri' 4 5class MastodonAPI 6 CODE_REDIRECT_URI = 'urn:ietf:wg:oauth:2.0:oob' 7 8 class UnauthenticatedError < StandardError 9 end 10 11 class UnexpectedResponseError < StandardError 12 end 13 14 class APIError < StandardError 15 attr_reader :response 16 17 def initialize(response) 18 @response = response 19 super("APIError #{response.code}: #{response.body}") 20 end 21 22 def status 23 response.code.to_i 24 end 25 end 26 27 MEDIA_CHECK_INTERVAL = 5.0 28 29 attr_accessor :access_token 30 31 def initialize(host, access_token = nil) 32 @host = host 33 @root = "https://#{@host}/api/v1" 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) 70 end 71 72 def account_info 73 raise UnauthenticatedError.new unless @access_token 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) 80 json 81 end 82 83 def account_statuses(user_id, params = {}) 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 94 95 def upload_media(data, filename, content_type, alt = nil) 96 url = URI("https://#{@host}/api/v2/media") 97 headers = { 'Authorization' => "Bearer #{@access_token}" } 98 99 form_data = [ 100 ['file', data, { :filename => filename, :content_type => content_type }] 101 ] 102 103 if alt 104 form_data << ['description', alt.force_encoding('ASCII-8BIT')] 105 end 106 107 request = Net::HTTP::Post.new(url, headers) 108 request.set_form(form_data, 'multipart/form-data') 109 110 response = Net::HTTP.start(url.hostname, url.port, :use_ssl => true) do |http| 111 http.request(request) 112 end 113 114 json = if response.code.to_i / 100 == 2 115 JSON.parse(response.body) 116 else 117 raise APIError.new(response) 118 end 119 120 while json['url'].nil? 121 sleep MEDIA_CHECK_INTERVAL 122 json = get_media(json['id']) 123 end 124 125 json 126 end 127 128 def get_media(media_id) 129 get_json("/media/#{media_id}") 130 end 131 132 def get_json(path, params = {}) 133 url = URI(path.start_with?('https://') ? path : @root + path) 134 url.query = URI.encode_www_form(params) if params 135 136 headers = {} 137 headers['Authorization'] = "Bearer #{@access_token}" if @access_token 138 139 response = Net::HTTP.get_response(url, headers) 140 status = response.code.to_i 141 142 if status / 100 == 2 143 JSON.parse(response.body) 144 elsif status / 100 == 3 145 get_json(response['Location']) 146 else 147 raise APIError.new(response) 148 end 149 end 150 151 def post_json(path, params = {}) 152 url = URI(path.start_with?('https://') ? path : @root + path) 153 154 headers = {} 155 headers['Authorization'] = "Bearer #{@access_token}" if @access_token 156 157 request = Net::HTTP::Post.new(url, headers) 158 request.form_data = params 159 160 response = Net::HTTP.start(url.hostname, url.port, :use_ssl => true) do |http| 161 http.request(request) 162 end 163 164 status = response.code.to_i 165 166 if status / 100 == 2 167 JSON.parse(response.body) 168 else 169 raise APIError.new(response) 170 end 171 end 172end