Toot toooooooot (Bluesky-Mastodon cross-poster)
1require 'json'
2require 'net/http'
3require 'uri'
4
5class 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 MEDIA_CHECK_INTERVAL = 5.0
26
27 attr_accessor :access_token
28
29 def initialize(host, access_token = nil)
30 @host = host
31 @root = "https://#{@host}/api/v1"
32 @access_token = access_token
33 end
34
35 def oauth_login_with_password(client_id, client_secret, email, password, scopes)
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 post_json("https://#{@host}/oauth/token", params)
46 end
47
48 def account_info
49 raise UnauthenticatedError.new unless @access_token
50 get_json("/accounts/verify_credentials")
51 end
52
53 def lookup_account(username)
54 json = get_json("/accounts/lookup", { acct: username })
55 raise UnexpectedResponseError.new unless json.is_a?(Hash) && json['id'].is_a?(String)
56 json
57 end
58
59 def account_statuses(user_id, params = {})
60 get_json("/accounts/#{user_id}/statuses", params)
61 end
62
63 def post_status(text, media_ids = nil, parent_id = nil)
64 params = { status: text }
65 params['media_ids[]'] = media_ids if media_ids
66 params['in_reply_to_id'] = parent_id if parent_id
67
68 post_json("/statuses", params)
69 end
70
71 def upload_media(data, filename, content_type, alt = nil)
72 url = URI("https://#{@host}/api/v2/media")
73 headers = { 'Authorization' => "Bearer #{@access_token}" }
74
75 form_data = [
76 ['file', data, { :filename => filename, :content_type => content_type }],
77 ['description', alt.to_s.force_encoding('ASCII-8BIT')]
78 ]
79
80 request = Net::HTTP::Post.new(url, headers)
81 request.set_form(form_data, 'multipart/form-data')
82
83 response = Net::HTTP.start(url.hostname, url.port, :use_ssl => true) do |http|
84 http.request(request)
85 end
86
87 json = if response.code.to_i / 100 == 2
88 JSON.parse(response.body)
89 else
90 raise APIError.new(response)
91 end
92
93 while json['url'].nil?
94 sleep MEDIA_CHECK_INTERVAL
95 json = get_media(json['id'])
96 end
97
98 json
99 end
100
101 def get_media(media_id)
102 get_json("/media/#{media_id}")
103 end
104
105 def get_json(path, params = {})
106 url = URI(path.start_with?('https://') ? path : @root + path)
107 url.query = URI.encode_www_form(params) if params
108
109 headers = {}
110 headers['Authorization'] = "Bearer #{@access_token}" if @access_token
111
112 response = Net::HTTP.get_response(url, headers)
113 status = response.code.to_i
114
115 if status / 100 == 2
116 JSON.parse(response.body)
117 elsif status / 100 == 3
118 get_json(response['Location'])
119 else
120 raise APIError.new(response)
121 end
122 end
123
124 def post_json(path, params = {})
125 url = URI(path.start_with?('https://') ? path : @root + path)
126
127 headers = {}
128 headers['Authorization'] = "Bearer #{@access_token}" if @access_token
129
130 request = Net::HTTP::Post.new(url, headers)
131 request.form_data = params
132
133 response = Net::HTTP.start(url.hostname, url.port, :use_ssl => true) do |http|
134 http.request(request)
135 end
136
137 status = response.code.to_i
138
139 if status / 100 == 2
140 JSON.parse(response.body)
141 else
142 raise APIError.new(response)
143 end
144 end
145end