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 instance_info
78 get_json("https://#{@host}/api/v2/instance")
79 end
80
81 def lookup_account(username)
82 json = get_json("/accounts/lookup", { acct: username })
83 raise UnexpectedResponseError.new unless json.is_a?(Hash) && json['id'].is_a?(String)
84 json
85 end
86
87 def account_statuses(user_id, params = {})
88 get_json("/accounts/#{user_id}/statuses", params)
89 end
90
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
142 })
143
144 json['statuses'] && json['statuses'][0]
145 end
146
147 def get_json(path, params = {})
148 url = URI(path.start_with?('https://') ? path : @root + path)
149 url.query = URI.encode_www_form(params) if params
150
151 headers = {}
152 headers['Authorization'] = "Bearer #{@access_token}" if @access_token
153
154 response = Net::HTTP.get_response(url, headers)
155 status = response.code.to_i
156
157 if status / 100 == 2
158 JSON.parse(response.body)
159 elsif status / 100 == 3
160 get_json(response['Location'])
161 else
162 raise APIError.new(response)
163 end
164 end
165
166 def post_json(path, params = {})
167 url = URI(path.start_with?('https://') ? path : @root + path)
168
169 headers = {}
170 headers['Authorization'] = "Bearer #{@access_token}" if @access_token
171
172 request = Net::HTTP::Post.new(url, headers)
173 request.form_data = params
174
175 response = Net::HTTP.start(url.hostname, url.port, :use_ssl => true) do |http|
176 http.request(request)
177 end
178
179 status = response.code.to_i
180
181 if status / 100 == 2
182 JSON.parse(response.body)
183 else
184 raise APIError.new(response)
185 end
186 end
187end