diff options
-rw-r--r-- | Mastodon.py | 326 | ||||
-rw-r--r-- | README.md | 2 |
2 files changed, 328 insertions, 0 deletions
diff --git a/Mastodon.py b/Mastodon.py new file mode 100644 index 0000000..f643127 --- /dev/null +++ b/Mastodon.py | |||
@@ -0,0 +1,326 @@ | |||
1 | |||
2 | # coding: utf-8 | ||
3 | |||
4 | # In[293]: | ||
5 | |||
6 | import requests | ||
7 | import os | ||
8 | import os.path | ||
9 | |||
10 | |||
11 | # In[310]: | ||
12 | |||
13 | class Mastodon: | ||
14 | """ Super basic but thorough and easy to use mastodon.social | ||
15 | api wrapper in python. | ||
16 | |||
17 | If anything is unclear, check the official API docs at | ||
18 | https://github.com/Gargron/mastodon/wiki/API | ||
19 | |||
20 | Presently, only username-password login is supported, somebody please | ||
21 | patch in Real Proper OAuth if desired. | ||
22 | |||
23 | KNOWN BUGS: Media api does not work, reason unclear. | ||
24 | """ | ||
25 | __DEFAULT_BASE_URL = 'https://mastodon.social' | ||
26 | |||
27 | ### | ||
28 | # Registering apps | ||
29 | ### | ||
30 | @staticmethod | ||
31 | def create_app(client_name, scopes = ['read', 'write', 'follow'], redirect_uris = None, to_file = None, api_base_url = __DEFAULT_BASE_URL): | ||
32 | """Creates a new app with given client_name and scopes (read, write, follow) | ||
33 | |||
34 | Specify redirect_uris if you want users to be redirected to a certain page after authenticating. | ||
35 | Specify to_file to persist your apps info to a file so you can use them in the constructor. | ||
36 | Specify api_base_url if you want to register an app on an instance different from the flagship one. | ||
37 | |||
38 | returns client_id and client_secret | ||
39 | """ | ||
40 | request_data = { | ||
41 | 'client_name': client_name, | ||
42 | 'scopes': " ".join(scopes) | ||
43 | } | ||
44 | |||
45 | if redirect_uris != None: | ||
46 | request_data['redirect_uris'] = redirect_uris; | ||
47 | else: | ||
48 | request_data['redirect_uris'] = 'urn:ietf:wg:oauth:2.0:oob'; | ||
49 | |||
50 | response = requests.post(api_base_url + '/api/v1/apps', data = request_data).json() | ||
51 | |||
52 | if to_file != None: | ||
53 | with open(to_file, 'w') as secret_file: | ||
54 | secret_file.write(response['client_id'] + '\n') | ||
55 | secret_file.write(response['client_secret'] + '\n') | ||
56 | |||
57 | return (response['client_id'], response['client_secret']) | ||
58 | |||
59 | ### | ||
60 | # Authentication, including constructor | ||
61 | ### | ||
62 | def __init__(self, client_id, client_secret = None, access_token = None, api_base_url = __DEFAULT_BASE_URL): | ||
63 | """Create a new API wrapper instance based on the given client_secret and client_id. If you | ||
64 | give a client_id and it is not a file, you must also give a secret. | ||
65 | |||
66 | You can also directly specify an access_token, directly or as a file. | ||
67 | |||
68 | Specify api_base_url if you wish to talk to an instance other than the flagship one. | ||
69 | If a file is given as client_id, read client ID and secret from that file | ||
70 | """ | ||
71 | self.api_base_url = api_base_url | ||
72 | self.client_id = client_id | ||
73 | self.client_secret = client_secret | ||
74 | self.access_token = access_token | ||
75 | |||
76 | if os.path.isfile(self.client_id): | ||
77 | with open(self.client_id, 'r') as secret_file: | ||
78 | self.client_id = secret_file.readline().rstrip() | ||
79 | self.client_secret = secret_file.readline().rstrip() | ||
80 | else: | ||
81 | if self.client_secret == None: | ||
82 | raise ValueError('Specified client id directly, but did not supply secret') | ||
83 | |||
84 | if self.access_token != None and os.path.isfile(self.access_token): | ||
85 | with open(self.access_token, 'r') as token_file: | ||
86 | self.access_token = token_file.readline().rstrip() | ||
87 | |||
88 | def log_in(self, username, password, scopes = ['read', 'write', 'follow'], to_file = None): | ||
89 | """Logs in and sets access_token to what was returned. | ||
90 | Can persist access token to file. | ||
91 | |||
92 | Returns the access_token, as well. | ||
93 | """ | ||
94 | params = self.__generate_params(locals()) | ||
95 | params['client_id'] = self.client_id | ||
96 | params['client_secret'] = self.client_secret | ||
97 | params['grant_type'] = 'password' | ||
98 | params['scope'] = " ".join(scopes) | ||
99 | |||
100 | response = self.__api_request('POST', '/oauth/token', params) | ||
101 | self.access_token = response['access_token'] | ||
102 | |||
103 | if to_file != None: | ||
104 | with open(to_file, 'w') as token_file: | ||
105 | token_file.write(response['access_token'] + '\n') | ||
106 | |||
107 | return response['access_token'] | ||
108 | |||
109 | ### | ||
110 | # Reading data: Timelines | ||
111 | ## | ||
112 | def timeline(self, timeline = 'home', max_id = None, since_id = None, limit = None): | ||
113 | """Returns statuses, most recent ones first. Timeline can be home, mentions, public | ||
114 | or tag/:hashtag""" | ||
115 | params = self.__generate_params(locals(), ['timeline']) | ||
116 | return self.__api_request('GET', '/api/v1/timelines/' + timeline, params) | ||
117 | |||
118 | ### | ||
119 | # Reading data: Statuses | ||
120 | ### | ||
121 | def status(self, id): | ||
122 | """Returns status.""" | ||
123 | return self.__api_request('GET', '/api/v1/statuses/' + str(id)) | ||
124 | |||
125 | def status_context(self, id): | ||
126 | """Returns ancestors and descendants of the status.""" | ||
127 | return self.__api_request('GET', '/api/v1/statuses/' + str(id) + '/context') | ||
128 | |||
129 | def status_reblogged_by(self, id): | ||
130 | """Returns a list of users that have reblogged a status.""" | ||
131 | return self.__api_request('GET', '/api/v1/statuses/' + str(id) + '/reblogged_by') | ||
132 | |||
133 | def status_favourited_by(self, id): | ||
134 | """Returns a list of users that have favourited a status.""" | ||
135 | return self.__api_request('GET', '/api/v1/statuses/' + str(id) + '/favourited_by') | ||
136 | |||
137 | ### | ||
138 | # Reading data: Accounts | ||
139 | ### | ||
140 | def account(self, id): | ||
141 | """Returns account.""" | ||
142 | return self.__api_request('GET', '/api/v1/accounts/' + str(id)) | ||
143 | |||
144 | def account_verify_credentials(self): | ||
145 | """Returns authenticated user's account.""" | ||
146 | return self.__api_request('GET', '/api/v1/accounts/verify_credentials') | ||
147 | |||
148 | def account_statuses(self, id, max_id = None, since_id = None, limit = None): | ||
149 | """Returns statuses by user. Same options as timeline are permitted.""" | ||
150 | params = self.__generate_params(locals(), ['id']) | ||
151 | return self.__api_request('GET', '/api/v1/accounts/' + str(id) + '/statuses') | ||
152 | |||
153 | def account_following(self, id): | ||
154 | """Returns users the given user is following.""" | ||
155 | return self.__api_request('GET', '/api/v1/accounts/' + str(id) + '/following') | ||
156 | |||
157 | def account_followers(self, id): | ||
158 | """Returns users the given user is followed by.""" | ||
159 | return self.__api_request('GET', '/api/v1/accounts/' + str(id) + '/followers') | ||
160 | |||
161 | def account_relationships(self, id): | ||
162 | """Returns relationships (following, followed_by, blocking) of the logged in user to | ||
163 | a given account. id can be a list.""" | ||
164 | params = self.__generate_params(locals()) | ||
165 | return self.__api_request('GET', '/api/v1/accounts/relationships', params) | ||
166 | |||
167 | def account_suggestions(self): | ||
168 | """Returns accounts that the system suggests the authenticated user to follow.""" | ||
169 | return self.__api_request('GET', '/api/v1/accounts/suggestions') | ||
170 | |||
171 | def account_search(self, q, limit = None): | ||
172 | """Returns matching accounts. Will lookup an account remotely if the search term is | ||
173 | in the username@domain format and not yet in the database.""" | ||
174 | params = self.__generate_params(locals()) | ||
175 | return self.__api_request('GET', '/api/v1/accounts/search', params) | ||
176 | |||
177 | ### | ||
178 | # Writing data: Statuses | ||
179 | ### | ||
180 | def status_post(self, status, in_reply_to_id = None, media_ids = None): | ||
181 | """Posts a status. Can optionally be in reply to another status and contain | ||
182 | up to four pieces of media (Uploaded via media_post()). | ||
183 | |||
184 | Returns the new status.""" | ||
185 | params = self.__generate_params(locals()) | ||
186 | return self.__api_request('POST', '/api/v1/statuses', params) | ||
187 | |||
188 | def toot(self, status): | ||
189 | """Synonym for status_post that only takes the status text as input.""" | ||
190 | return self.status_post(status) | ||
191 | |||
192 | def status_delete(self, id): | ||
193 | """Deletes a status""" | ||
194 | return self.__api_request('DELETE', '/api/v1/statuses/' + str(id)) | ||
195 | |||
196 | def status_reblog(self, id): | ||
197 | """Reblogs a status. | ||
198 | |||
199 | Returns a new status that wraps around the reblogged one.""" | ||
200 | return self.__api_request('POST', '/api/v1/statuses/' + str(id) + "/reblog") | ||
201 | |||
202 | def status_unreblog(self, id): | ||
203 | """Un-reblogs a status. | ||
204 | |||
205 | Returns the status that used to be reblogged.""" | ||
206 | return self.__api_request('POST', '/api/v1/statuses/' + str(id) + "/unreblog") | ||
207 | |||
208 | def status_favourite(self, id): | ||
209 | """Favourites a status. | ||
210 | |||
211 | Returns the favourited status.""" | ||
212 | return self.__api_request('POST', '/api/v1/statuses/' + str(id) + "/favourite") | ||
213 | |||
214 | def status_unfavourite(self, id): | ||
215 | """Favourites a status. | ||
216 | |||
217 | Returns the un-favourited status.""" | ||
218 | return self.__api_request('POST', '/api/v1/statuses/' + str(id) + "/unfavourite") | ||
219 | |||
220 | ### | ||
221 | # Writing data: Statuses | ||
222 | ### | ||
223 | def account_follow(self, id): | ||
224 | """Follows a user. | ||
225 | |||
226 | Returns the updated relationship to the user.""" | ||
227 | return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/follow") | ||
228 | |||
229 | def account_unfollow(self, id): | ||
230 | """Unfollows a user. | ||
231 | |||
232 | Returns the updated relationship to the user.""" | ||
233 | return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/unfollow") | ||
234 | |||
235 | def account_block(self, id): | ||
236 | """Blocks a user. | ||
237 | |||
238 | Returns the updated relationship to the user.""" | ||
239 | return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/block") | ||
240 | |||
241 | def account_unblock(self, id): | ||
242 | """Unblocks a user. | ||
243 | |||
244 | Returns the updated relationship to the user.""" | ||
245 | return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/unblock") | ||
246 | |||
247 | ### | ||
248 | # Writing data: Media | ||
249 | ### | ||
250 | def media_post(self, media_file): | ||
251 | """Posts an image. media_file can either be image data or | ||
252 | a file name. | ||
253 | |||
254 | Returns the ID of the media that can then be used in status_post().""" | ||
255 | if os.path.isfile(media_file): | ||
256 | media_file = open(media_file, 'rb') | ||
257 | |||
258 | return self.__api_request('POST', '/api/v1/media', files = {'file': media_file}) | ||
259 | |||
260 | ### | ||
261 | # Internal helpers, dragons probably | ||
262 | ### | ||
263 | def __api_request(self, method, endpoint, params = {}, files = {}): | ||
264 | """ Internal API request helper.""" | ||
265 | response = None | ||
266 | headers = None | ||
267 | |||
268 | if self.access_token != None: | ||
269 | headers = {'Authorization': 'Bearer ' + self.access_token} | ||
270 | |||
271 | if method == 'GET': | ||
272 | response = requests.get(self.api_base_url + endpoint, data = params, headers = headers, files = files) | ||
273 | |||
274 | if method == 'POST': | ||
275 | response = requests.post(self.api_base_url + endpoint, data = params, headers = headers, files = files) | ||
276 | |||
277 | if method == 'DELETE': | ||
278 | response = requests.delete(self.api_base_url + endpoint, data = params, headers = headers, files = files) | ||
279 | |||
280 | if response.status_code == 404: | ||
281 | raise IOError('Endpoint not found.') | ||
282 | |||
283 | if response.status_code == 500: | ||
284 | raise IOError('General API problem.') | ||
285 | |||
286 | return response.json() | ||
287 | |||
288 | def __generate_params(self, params, exclude = []): | ||
289 | """Internal named-parameters-to-dict helper""" | ||
290 | params = dict(params) | ||
291 | |||
292 | del params['self'] | ||
293 | param_keys = list(params.keys()) | ||
294 | for key in param_keys: | ||
295 | if params[key] == None or key in exclude: | ||
296 | del params[key] | ||
297 | |||
298 | param_keys = list(params.keys()) | ||
299 | for key in param_keys: | ||
300 | if isinstance(params[key], list): | ||
301 | params[key + "[]"] = params[key] | ||
302 | del params[key] | ||
303 | |||
304 | return params | ||
305 | |||
306 | |||
307 | # In[311]: | ||
308 | |||
309 | mastodon = Mastodon(client_id = "quasibot_client_credentials.txt", access_token = 'quasibot_user_credentials.txt') | ||
310 | |||
311 | |||
312 | # In[312]: | ||
313 | |||
314 | #mastodon.account_relationships(12355) | ||
315 | mastodon.media_post('D:\\img\\google.png') | ||
316 | |||
317 | |||
318 | # In[127]: | ||
319 | |||
320 | |||
321 | |||
322 | |||
323 | # In[ ]: | ||
324 | |||
325 | |||
326 | |||
@@ -1,2 +1,4 @@ | |||
1 | # Mastodon.py | 1 | # Mastodon.py |
2 | Python wrapper for the Mastodon ( https://github.com/Gargron/mastodon/ ) API. | 2 | Python wrapper for the Mastodon ( https://github.com/Gargron/mastodon/ ) API. |
3 | |||
4 | Media uploads currently broken, will be fixed Soon. | ||