diff options
Diffstat (limited to 'mastodon')
-rw-r--r-- | mastodon/Mastodon.py | 150 |
1 files changed, 113 insertions, 37 deletions
diff --git a/mastodon/Mastodon.py b/mastodon/Mastodon.py index 493fb40..ee11496 100644 --- a/mastodon/Mastodon.py +++ b/mastodon/Mastodon.py | |||
@@ -1,19 +1,18 @@ | |||
1 | # coding: utf-8 | 1 | # coding: utf-8 |
2 | 2 | ||
3 | import requests | 3 | |
4 | import os | 4 | import os |
5 | from urllib.parse import urlencode | ||
5 | import os.path | 6 | import os.path |
6 | import mimetypes | 7 | import mimetypes |
7 | import time | 8 | import time |
8 | import random | 9 | import random |
9 | import string | 10 | import string |
10 | import pytz | ||
11 | import datetime | 11 | import datetime |
12 | import dateutil | 12 | import dateutil |
13 | import dateutil.parser | 13 | import dateutil.parser |
14 | |||
15 | from contextlib import closing | 14 | from contextlib import closing |
16 | 15 | import requests | |
17 | 16 | ||
18 | class Mastodon: | 17 | class Mastodon: |
19 | """ | 18 | """ |
@@ -22,9 +21,10 @@ class Mastodon: | |||
22 | 21 | ||
23 | If anything is unclear, check the official API docs at | 22 | If anything is unclear, check the official API docs at |
24 | https://github.com/Gargron/mastodon/wiki/API | 23 | https://github.com/Gargron/mastodon/wiki/API |
25 | 24 | ||
26 | Presently, only username-password login is supported, somebody please | 25 | Supported: |
27 | patch in Real Proper OAuth if desired. | 26 | Username-Password Login |
27 | OAuth2 | ||
28 | """ | 28 | """ |
29 | __DEFAULT_BASE_URL = 'https://mastodon.social' | 29 | __DEFAULT_BASE_URL = 'https://mastodon.social' |
30 | __DEFAULT_TIMEOUT = 300 | 30 | __DEFAULT_TIMEOUT = 300 |
@@ -34,7 +34,7 @@ class Mastodon: | |||
34 | # Registering apps | 34 | # Registering apps |
35 | ### | 35 | ### |
36 | @staticmethod | 36 | @staticmethod |
37 | def create_app(client_name, scopes = ['read', 'write', 'follow'], redirect_uris = None, to_file = None, api_base_url = __DEFAULT_BASE_URL, request_timeout = __DEFAULT_TIMEOUT): | 37 | def create_app(client_name, scopes = ['read', 'write', 'follow'], redirect_uris = None, website = None, to_file = None, api_base_url = __DEFAULT_BASE_URL, request_timeout = __DEFAULT_TIMEOUT): |
38 | """ | 38 | """ |
39 | Create a new app with given client_name and scopes (read, write, follow) | 39 | Create a new app with given client_name and scopes (read, write, follow) |
40 | 40 | ||
@@ -53,12 +53,15 @@ class Mastodon: | |||
53 | } | 53 | } |
54 | 54 | ||
55 | try: | 55 | try: |
56 | if redirect_uris != None: | 56 | if redirect_uris is not None: |
57 | request_data['redirect_uris'] = redirect_uris; | 57 | request_data['redirect_uris'] = redirect_uris; |
58 | else: | 58 | else: |
59 | request_data['redirect_uris'] = 'urn:ietf:wg:oauth:2.0:oob'; | 59 | request_data['redirect_uris'] = 'urn:ietf:wg:oauth:2.0:oob'; |
60 | 60 | if website is not None: | |
61 | response = requests.post(api_base_url + '/api/v1/apps', data = request_data, timeout = request_timeout).json() | 61 | request_data['website'] = website |
62 | |||
63 | response = requests.post(api_base_url + '/api/v1/apps', data = request_data, timeout = request_timeout) | ||
64 | response = response.json() | ||
62 | except Exception as e: | 65 | except Exception as e: |
63 | import traceback | 66 | import traceback |
64 | traceback.print_exc() | 67 | traceback.print_exc() |
@@ -102,6 +105,8 @@ class Mastodon: | |||
102 | self.access_token = access_token | 105 | self.access_token = access_token |
103 | self.debug_requests = debug_requests | 106 | self.debug_requests = debug_requests |
104 | self.ratelimit_method = ratelimit_method | 107 | self.ratelimit_method = ratelimit_method |
108 | self._token_expired = datetime.datetime.now() | ||
109 | self._refresh_token = None | ||
105 | 110 | ||
106 | self.ratelimit_limit = 150 | 111 | self.ratelimit_limit = 150 |
107 | self.ratelimit_reset = time.time() | 112 | self.ratelimit_reset = time.time() |
@@ -125,32 +130,92 @@ class Mastodon: | |||
125 | if self.access_token != None and os.path.isfile(self.access_token): | 130 | if self.access_token != None and os.path.isfile(self.access_token): |
126 | with open(self.access_token, 'r') as token_file: | 131 | with open(self.access_token, 'r') as token_file: |
127 | self.access_token = token_file.readline().rstrip() | 132 | self.access_token = token_file.readline().rstrip() |
128 | 133 | ||
129 | def log_in(self, username, password, scopes = ['read', 'write', 'follow'], to_file = None): | 134 | |
130 | """ | 135 | @property |
131 | Log in and sets access_token to what was returned. Note that your | 136 | def token_expired(self) -> bool: |
132 | username is the e-mail you use to log in into mastodon. | 137 | if self._token_expired < datetime.datetime.now(): |
133 | 138 | return True | |
134 | Can persist access token to file, to be used in the constructor. | 139 | else: |
135 | 140 | return False | |
136 | Will throw a MastodonIllegalArgumentError if username / password | 141 | |
137 | are wrong, scopes are not valid or granted scopes differ from requested. | 142 | @token_expired.setter |
138 | 143 | def token_expired(self, value: int): | |
139 | Returns the access_token. | 144 | self._token_expired = datetime.datetime.now() + datetime.timedelta(seconds=value) |
140 | """ | 145 | return |
141 | params = self.__generate_params(locals()) | 146 | |
147 | @property | ||
148 | def refresh_token(self) -> str: | ||
149 | return self._refresh_token | ||
150 | |||
151 | @refresh_token.setter | ||
152 | def refresh_token(self, value): | ||
153 | self._refresh_token = value | ||
154 | return | ||
155 | |||
156 | def auth_request_url(self, client_id: str = None, redirect_uris: str = "urn:ietf:wg:oauth:2.0:oob") -> str: | ||
157 | """Returns the url that a client needs to request the grant from the server. | ||
158 | https://mastodon.social/oauth/authorize?client_id=XXX&response_type=code&redirect_uris=YYY | ||
159 | """ | ||
160 | if client_id is None: | ||
161 | client_id = self.client_id | ||
162 | else: | ||
163 | if os.path.isfile(client_id): | ||
164 | with open(client_id, 'r') as secret_file: | ||
165 | client_id = secret_file.readline().rstrip() | ||
166 | |||
167 | params = {} | ||
168 | params['client_id'] = client_id | ||
169 | params['response_type'] = "code" | ||
170 | params['redirect_uri'] = redirect_uris | ||
171 | formatted_params = urlencode(params) | ||
172 | return "".join([self.api_base_url, "/oauth/authorize?", formatted_params]) | ||
173 | |||
174 | def log_in(self, username: str = None, password: str = None,\ | ||
175 | code: str = None, redirect_uri: str = "urn:ietf:wg:oauth:2.0:oob", refresh_token: str = None,\ | ||
176 | scopes: list = ['read', 'write', 'follow'], to_file: str = None) -> str: | ||
177 | """ | ||
178 | Docs: https://github.com/doorkeeper-gem/doorkeeper/wiki/Interacting-as-an-OAuth-client-with-Doorkeeper | ||
179 | |||
180 | Notes: | ||
181 | Your username is the e-mail you use to log in into mastodon. | ||
182 | |||
183 | Can persist access token to file, to be used in the constructor. | ||
184 | |||
185 | Supports refresh_token but Mastodon.social doesn't implement it at the moment. | ||
186 | |||
187 | Handles password, authorization_code, and refresh_token authentication. | ||
188 | |||
189 | Will throw a MastodonIllegalArgumentError if username / password | ||
190 | are wrong, scopes are not valid or granted scopes differ from requested. | ||
191 | |||
192 | Returns: | ||
193 | str @access_token | ||
194 | """ | ||
195 | if username is not None and password is not None: | ||
196 | params = self.__generate_params(locals(), ['scopes', 'to_file', 'code', 'refresh_token']) | ||
197 | params['grant_type'] = 'password' | ||
198 | elif code is not None: | ||
199 | params = self.__generate_params(locals(), ['scopes', 'to_file', 'username', 'password', 'refresh_token']) | ||
200 | params['grant_type'] = 'authorization_code' | ||
201 | elif refresh_token is not None: | ||
202 | params = self.__generate_params(locals(), ['scopes', 'to_file', 'username', 'password', 'code']) | ||
203 | params['grant_type'] = 'refresh_token' | ||
204 | else: | ||
205 | raise MastodonIllegalArgumentError('Invalid user name, password, redirect_uris or scopes') | ||
206 | |||
142 | params['client_id'] = self.client_id | 207 | params['client_id'] = self.client_id |
143 | params['client_secret'] = self.client_secret | 208 | params['client_secret'] = self.client_secret |
144 | params['grant_type'] = 'password' | 209 | |
145 | params['scope'] = " ".join(scopes) | ||
146 | |||
147 | try: | 210 | try: |
148 | response = self.__api_request('POST', '/oauth/token', params, do_ratelimiting = False) | 211 | response = self.__api_request('POST', '/oauth/token', params, do_ratelimiting = False) |
149 | self.access_token = response['access_token'] | 212 | self.access_token = response['access_token'] |
213 | self.refresh_token = response.get('refresh_token') | ||
214 | self.token_expired = int(response.get('expires_in', 0)) | ||
150 | except Exception as e: | 215 | except Exception as e: |
151 | import traceback | 216 | import traceback |
152 | traceback.print_exc() | 217 | traceback.print_exc() |
153 | raise MastodonIllegalArgumentError('Invalid user name, password or scopes: %s' % e) | 218 | raise MastodonIllegalArgumentError('Invalid user name, password, redirect_uris or scopes: %s' % e) |
154 | 219 | ||
155 | requested_scopes = " ".join(sorted(scopes)) | 220 | requested_scopes = " ".join(sorted(scopes)) |
156 | received_scopes = " ".join(sorted(response["scope"].split(" "))) | 221 | received_scopes = " ".join(sorted(response["scope"].split(" "))) |
@@ -335,6 +400,17 @@ class Mastodon: | |||
335 | """ | 400 | """ |
336 | params = self.__generate_params(locals()) | 401 | params = self.__generate_params(locals()) |
337 | return self.__api_request('GET', '/api/v1/accounts/search', params) | 402 | return self.__api_request('GET', '/api/v1/accounts/search', params) |
403 | |||
404 | |||
405 | def content_search(self, q, resolve = False): | ||
406 | """ | ||
407 | Fetch matching hashtags, accounts and statuses. Will search federated | ||
408 | instances if resolve is True. | ||
409 | |||
410 | Returns a dict of lists. | ||
411 | """ | ||
412 | params = self.__generate_params(locals()) | ||
413 | return self.__api_request('GET', '/api/v1/search', params) | ||
338 | 414 | ||
339 | ### | 415 | ### |
340 | # Reading data: Mutes and Blocks | 416 | # Reading data: Mutes and Blocks |
@@ -568,7 +644,7 @@ class Mastodon: | |||
568 | Returns a media dict. This contains the id that can be used in | 644 | Returns a media dict. This contains the id that can be used in |
569 | status_post to attach the media file to a toot. | 645 | status_post to attach the media file to a toot. |
570 | """ | 646 | """ |
571 | if os.path.isfile(media_file) and mime_type == None: | 647 | if mime_type == None and os.path.isfile(media_file): |
572 | mime_type = mimetypes.guess_type(media_file)[0] | 648 | mime_type = mimetypes.guess_type(media_file)[0] |
573 | media_file = open(media_file, 'rb') | 649 | media_file = open(media_file, 'rb') |
574 | 650 | ||
@@ -734,13 +810,13 @@ class Mastodon: | |||
734 | if self.ratelimit_method == "throw": | 810 | if self.ratelimit_method == "throw": |
735 | raise MastodonRatelimitError("Hit rate limit.") | 811 | raise MastodonRatelimitError("Hit rate limit.") |
736 | 812 | ||
737 | if self.ratelimit_method == "wait" or self.ratelimit_method == "pace": | 813 | if self.ratelimit_method == "wait" or self.ratelimit_method == "pace": |
738 | to_next = self.ratelimit_reset - time.time() | 814 | to_next = self.ratelimit_reset - time.time() |
739 | if to_next > 0: | 815 | if to_next > 0: |
740 | # As a precaution, never sleep longer than 5 minutes | 816 | # As a precaution, never sleep longer than 5 minutes |
741 | to_next = min(to_next, 5 * 60) | 817 | to_next = min(to_next, 5 * 60) |
742 | time.sleep(to_next) | 818 | time.sleep(to_next) |
743 | request_complete = False | 819 | request_complete = False |
744 | 820 | ||
745 | return response | 821 | return response |
746 | 822 | ||