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