You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1108 lines
47 KiB

import os
import os.path
import mimetypes
import time
import random
import string
import datetime
import collections
import pytz
import requests
import dateutil
import dateutil.parser
import re
import copy
import sys
import six
from decorator import decorate
import hashlib
import pdb
###
# Version check functions, including decorator and parser
###
def parse_version_string(version_string):
"""Parses a semver version string, stripping off "rc" stuff if present."""
string_parts = version_string.split(".")
version_parts = [
int(re.match("([0-9]*)", string_parts[0]).group(0)),
int(re.match("([0-9]*)", string_parts[1]).group(0)),
int(re.match("([0-9]*)", string_parts[2]).group(0))
]
return version_parts
def bigger_version(version_string_a, version_string_b):
"""Returns the bigger version of two version strings."""
major_a, minor_a, patch_a = parse_version_string(version_string_a)
major_b, minor_b, patch_b = parse_version_string(version_string_b)
if major_a > major_b:
return version_string_a
elif major_a == major_b and minor_a > minor_b:
return version_string_a
elif major_a == major_b and minor_a == minor_b and patch_a > patch_b:
return version_string_a
return version_string_b
def api_version(created_ver, last_changed_ver, return_value_ver):
"""Version check decorator. Currently only checks Bigger Than."""
def api_min_version_decorator(function):
def wrapper(function, self, *args, **kwargs):
if not self.version_check_mode == "none":
if self.version_check_mode == "created":
version = created_ver
else:
version = bigger_version(last_changed_ver, return_value_ver)
major, minor, patch = parse_version_string(version)
if major > self.akkoma_major:
raise AkkomaVersionError("Version check failed (Need version " + version + ")")
elif major == self.akkoma_major and minor > self.akkoma_minor:
print(self.akkoma_minor)
raise AkkomaVersionError("Version check failed (Need version " + version + ")")
elif major == self.akkoma_major and minor == self.akkoma_minor and patch > self.akkoma_patch:
raise AkkomaVersionError("Version check failed (Need version " + version + ", patch is " + str(self.akkoma_patch) + ")")
return function(self, *args, **kwargs)
function.__doc__ = function.__doc__ + "\n\n *Added: Akkoma v" + created_ver + ", last changed: Akkoma v" + last_changed_ver + "*"
return decorate(function, wrapper)
return api_min_version_decorator
###
# Dict helper class.
# Defined at top level so it can be pickled.
###
class AttribAccessDict(dict):
def __getattr__(self, attr):
if attr in self:
return self[attr]
else:
raise AttributeError("Attribute not found: " + str(attr))
def __setattr__(self, attr, val):
if attr in self:
raise AttributeError("Attribute-style access is read only")
super(AttribAccessDict, self).__setattr__(attr, val)
class Akkoma:
"""
Easy to use Akkoma API wrapper.
Main repository at https://git.mastodont.cat/spla/Akkoma.py
"""
__DEFAULT_BASE_URL = 'https://akkoma.mastodont.cat'
__DEFAULT_TIMEOUT = 300
__DEFAULT_SCOPES = ['read', 'write', 'follow', 'push', 'admin']
__SCOPE_SETS = {
'read': [
'read:accounts',
'read:blocks',
'read:favourites',
'read:filters',
'read:follows',
'read:lists',
'read:mutes',
'read:notifications',
'read:search',
'read:statuses',
'read:bookmarks'
],
'write': [
'write:accounts',
'write:blocks',
'write:favourites',
'write:filters',
'write:follows',
'write:lists',
'write:media',
'write:mutes',
'write:notifications',
'write:reports',
'write:statuses',
'write:bookmarks'
],
'follow': [
'read:blocks',
'read:follows',
'read:mutes',
'write:blocks',
'write:follows',
'write:mutes',
],
'admin:read': [
'admin:read:accounts',
'admin:read:reports',
],
'admin:write': [
'admin:write:accounts',
'admin:write:reports',
],
}
# Dict versions
__DICT_VERSION_APPLICATION = "2.7.2"
__DICT_VERSION_MENTION = "1.0.0"
__DICT_VERSION_MEDIA = "2.8.2"
__DICT_VERSION_ACCOUNT = "3.1.0"
__DICT_VERSION_POLL = "2.8.0"
__DICT_VERSION_STATUS = bigger_version(bigger_version(bigger_version(bigger_version(bigger_version("3.1.0",
__DICT_VERSION_MEDIA), __DICT_VERSION_ACCOUNT), __DICT_VERSION_APPLICATION), __DICT_VERSION_MENTION), __DICT_VERSION_POLL)
__DICT_VERSION_NOTIFICATION = bigger_version(bigger_version("1.0.0", __DICT_VERSION_ACCOUNT), __DICT_VERSION_STATUS)
@staticmethod
def create_app(app_name, scopes=__DEFAULT_SCOPES, redirect_uris=None, website=None, to_file=None, api_base_url=__DEFAULT_BASE_URL,
request_timeout=__DEFAULT_TIMEOUT, session=None):
"""
Create a new app with given app_name, redirect_uris and website.
Specify `api_base_url` if you want to register an app on an different instance.
Specify `website` if you want to give a website for your app.
Returns `client_id` and `client_secret`, both as strings.
"""
api_base_url = Akkoma.__protocolize(api_base_url)
request_data = {
'client_name': app_name,
#'redirect_uris': redirect_uris,
#'website': website
'scopes': " ".join(scopes)
}
try:
if redirect_uris is not None:
if isinstance(redirect_uris, (list, tuple)):
redirect_uris = "\n".join(list(redirect_uris))
request_data['redirect_uris'] = redirect_uris
else:
request_data['redirect_uris'] = 'urn:ietf:wg:oauth:2.0:oob'
if website is not None:
request_data['website'] = website
if session:
ret = session.post(api_base_url + '/api/v1/apps', data=request_data, timeout=request_timeout)
response = ret.json()
else:
response = requests.post(api_base_url + '/api/v1/apps', data=request_data, timeout=request_timeout)
response = response.json()
except Exception as e:
raise AkkomaNetworkError("Could not complete request: %s" % e)
if to_file is not None:
with open(to_file, 'w') as secret_file:
secret_file.write(response['client_id'] + "\n")
secret_file.write(response['client_secret'] + "\n")
secret_file.write(api_base_url + "\n")
return (response['client_id'], response['client_secret'])
###
# Authentication, including constructor
###
def __init__(self, client_id=None, client_secret=None, access_token=None,
api_base_url=None, debug_requests=False,
ratelimit_method="wait", ratelimit_pacefactor=1.1,
request_timeout=__DEFAULT_TIMEOUT, akkoma_version=None,
version_check_mode = "created", session=None, feature_set="mainline"):
"""
Create a new API wrapper instance based on the given `client_secret` and `client_id`. If you
give a `client_id` and it is not a file, you must also give a secret. If you specify an
`access_token` then you don't need to specify a `client_id`. It is allowed to specify
neither - in this case, you will be restricted to only using endpoints that do not
require authentication. If a file is given as `client_id`, client ID, secret and
base url are read from that file.
You can also specify an `access_token`, directly or as a file (as written by `log_in()`_). If
a file is given, Akkoma.py also tries to load the base URL from this file, if present. A
client id and secret are not required in this case.
Akkoma.py can try to respect rate limits in several ways, controlled by `ratelimit_method`.
"throw" makes functions throw a `AkkomaRatelimitError` when the rate
limit is hit. "wait" mode will, once the limit is hit, wait and retry the request as soon
as the rate limit resets, until it succeeds. "pace" works like throw, but tries to wait in
between calls so that the limit is generally not hit (How hard it tries to not hit the rate
limit can be controlled by ratelimit_pacefactor). The default setting is "wait". Note that
even in "wait" and "pace" mode, requests can still fail due to network or other problems! Also
note that "pace" and "wait" are NOT thread safe.
Specify `api_base_url` if you wish to talk to an instance other than the flagship one. When
reading from client id or access token files as written by Akkoma.py 1.5.0 or larger,
this can be omitted.
By default, a timeout of 300 seconds is used for all requests. If you wish to change this,
pass the desired timeout (in seconds) as `request_timeout`.
For fine-tuned control over the requests object use `session` with a requests.Session.
The `akkoma_version` parameter can be used to specify the version of Akkoma that Akkoma.py will
expect to be installed on the server. The function will throw an error if an unparseable
Version is specified. If no version is specified, Akkoma.py will set `akkoma_version` to the
detected version.
The version check mode can be set to "created" (the default behaviour), "changed" or "none". If set to
"created", Akkoma.py will throw an error if the version of Akkoma it is connected to is too old
to have an endpoint. If it is set to "changed", it will throw an error if the endpoints behaviour has
changed after the version of Akkoma that is connected has been released. If it is set to "none",
version checking is disabled.
`feature_set` can be used to enable behaviour specific to non-mainline Akkoma API implementations.
Details are documented in the functions that provide such functionality. Currently supported feature
sets are `mainline`, `fedibird` and `pleroma`.
"""
self.api_base_url = None
if not api_base_url is None:
self.api_base_url = Akkoma.__protocolize(api_base_url)
self.client_id = client_id
self.client_secret = client_secret
self.access_token = access_token
self.debug_requests = debug_requests
self.ratelimit_method = ratelimit_method
self._token_expired = datetime.datetime.now()
self._refresh_token = None
self.__logged_in_id = None
self.ratelimit_limit = 300
self.ratelimit_reset = time.time()
self.ratelimit_remaining = 300
self.ratelimit_lastcall = time.time()
self.ratelimit_pacefactor = ratelimit_pacefactor
self.request_timeout = request_timeout
if session:
self.session = session
else:
self.session = requests.Session()
self.feature_set = feature_set
if not self.feature_set in ["mainline", "fedibird", "pleroma"]:
raise AkkomaIllegalArgumentError('Requested invalid feature set')
# Token loading
if self.client_id is not None:
if os.path.isfile(self.client_id):
with open(self.client_id, 'r') as secret_file:
self.client_id = secret_file.readline().rstrip()
self.client_secret = secret_file.readline().rstrip()
try_base_url = secret_file.readline().rstrip()
if (not try_base_url is None) and len(try_base_url) != 0:
try_base_url = Akkoma.__protocolize(try_base_url)
if not (self.api_base_url is None or try_base_url == self.api_base_url):
raise AkkomaIllegalArgumentError('Mismatch in base URLs between files and/or specified')
self.api_base_url = try_base_url
else:
if self.client_secret is None:
raise AkkomaIllegalArgumentError('Specified client id directly, but did not supply secret')
if self.access_token is not None and os.path.isfile(self.access_token):
with open(self.access_token, 'r') as token_file:
self.access_token = token_file.readline().rstrip()
try_base_url = token_file.readline().rstrip()
if (not try_base_url is None) and len(try_base_url) != 0:
try_base_url = Akkoma.__protocolize(try_base_url)
if not (self.api_base_url is None or try_base_url == self.api_base_url):
raise AkkomaIllegalArgumentError('Mismatch in base URLs between files and/or specified')
self.api_base_url = try_base_url
# Versioning
if akkoma_version == None:
self.retrieve_akkoma_version()
else:
try:
self.akkoma_major, self.akkoma_minor, self.akkoma_patch = parse_version_string(akkoma_version)
except:
raise AkkomaVersionError("Bad version specified")
if not version_check_mode in ["created", "changed", "none"]:
raise AkkomaIllegalArgumentError("Invalid version check method.")
self.version_check_mode = version_check_mode
# Ratelimiting parameter check
if ratelimit_method not in ["throw", "wait", "pace"]:
raise AkkomaIllegalArgumentError("Invalid ratelimit method.")
def retrieve_akkoma_version(self):
"""
Determine installed akkoma version and set major, minor and patch (not including RC info) accordingly.
Returns the version string, possibly including rc info.
"""
try:
version_str = self.__instance()["version"]
except:
# instance() was added in 1.1.0, so our best guess is 1.0.0.
version_str = "1.0.0"
self.akkoma_major, self.akkoma_minor, self.akkoma_patch = parse_version_string(version_str)
return version_str
def verify_minimum_version(self, version_str, cached=False):
"""
Update version info from server and verify that at least the specified version is present.
If you specify "cached", the version info update part is skipped.
Returns True if version requirement is satisfied, False if not.
"""
if not cached:
self.retrieve_akkoma_version()
major, minor, patch = parse_version_string(version_str)
if major > self.akkoma_major:
return False
elif major == self.akkoma_major and minor > self.akkoma_minor:
return False
elif major == self.akkoma_major and minor == self.akkoma_minor and patch > self.akkoma_patch:
return False
return True
def log_in(self, client_id=None, client_secret=None, grant_type=None, username=None, password=None, code=None, redirect_uri="urn:ietf:wg:oauth:2.0:oob", refresh_token=None, scopes=__DEFAULT_SCOPES, to_file=None):
"""
Get the access token for a user.
The username is the e-mail used to log in into akkoma.
Can persist access token to file `to_file`, to be used in the constructor.
Handles password and OAuth-based authorization.
Will throw a `AkkomaIllegalArgumentError` if the OAuth or the
username / password credentials given are incorrect, and
`AkkomaAPIError` if all of the requested scopes were not granted.
For OAuth2, obtain a code via having your user go to the url returned by
`auth_request_url()`_ and pass it as the code parameter. In this case,
make sure to also pass the same redirect_uri parameter as you used when
generating the auth request URL.
Returns the access token as a string.
"""
if username is not None and password is not None:
params = self.__generate_params(locals(), ['scopes', 'to_file', 'code', 'refresh_token'])
params['grant_type'] = 'password'
elif code is not None:
params = self.__generate_params(locals(), ['scopes', 'to_file', 'username', 'password', 'refresh_token'])
params['grant_type'] = 'authorization_code'
elif refresh_token is not None:
params = self.__generate_params(locals(), ['scopes', 'to_file', 'username', 'password', 'code'])
params['grant_type'] = 'refresh_token'
else:
raise AkkomaIllegalArgumentError('Invalid arguments given. username and password or code are required.')
params['client_id'] = self.client_id
params['client_secret'] = self.client_secret
params['username'] = username
params['password'] = password
#params['scope'] = " ".join(scopes)
try:
response = self.__api_request('POST', '/oauth/token', params, do_ratelimiting=False)
self.access_token = response['access_token']
self.__set_refresh_token(response.get('refresh_token'))
self.__set_token_expired(int(response.get('expires_in', 0)))
except Exception as e:
if username is not None or password is not None:
raise AkkomaIllegalArgumentError('Invalid user name, password, or redirect_uris: %s' % e)
elif code is not None:
raise AkkomaIllegalArgumentError('Invalid access token or redirect_uris: %s' % e)
else:
raise AkkomaIllegalArgumentError('Invalid request: %s' % e)
received_scopes = response["scope"].split(" ")
for scope_set in self.__SCOPE_SETS.keys():
if scope_set in received_scopes:
received_scopes += self.__SCOPE_SETS[scope_set]
if not set(scopes) <= set(received_scopes):
raise AkkomaAPIError(
'Granted scopes "' + " ".join(received_scopes) + '" do not contain all of the requested scopes "' + " ".join(scopes) + '".')
if to_file is not None:
with open(to_file, 'w') as token_file:
token_file.write(response['access_token'] + "\n")
token_file.write(self.api_base_url + "\n")
self.__logged_in_id = None
return response['access_token']
###
# Reading data: Notifications
###
#@api_version("1.0.0", "2.9.0", __DICT_VERSION_NOTIFICATION)
def notifications(self, id=None, account_id=None, max_id=None, min_id=None, since_id=None, limit=None, mentions_only=None):
"""
Fetch notifications (mentions, favourites, reblogs, follows) for the logged-in
user. Pass `account_id` to get only notifications originating from the given account.
Can be passed an `id` to fetch a single notification.
Returns a list of `notification dicts`_.
"""
if max_id != None:
max_id = self.__unpack_id(max_id)
if min_id != None:
min_id = self.__unpack_id(min_id)
if since_id != None:
since_id = self.__unpack_id(since_id)
if account_id != None:
account_id = self.__unpack_id(account_id)
if id is None:
params = self.__generate_params(locals(), ['id'])
return self.__api_request('GET', '/api/v1/notifications', params)
else:
id = self.__unpack_id(id)
url = '/api/v1/notifications/{0}'.format(str(id))
return self.__api_request('GET', url)
###
# Reading data: Accounts
###
@api_version("1.0.0", "1.0.0", __DICT_VERSION_ACCOUNT)
def account(self, id):
"""
Fetch account information by user `id`.
Does not require authentication for publicly visible accounts.
Returns a `user dict`_.
"""
id = self.__unpack_id(id)
url = '/api/v1/accounts/{0}'.format(str(id))
return self.__api_request('GET', url)
@api_version("1.0.0", "2.1.0", __DICT_VERSION_ACCOUNT)
def account_verify_credentials(self):
"""
Fetch logged-in user's account information.
Returns a `user dict`_ (Starting from 2.1.0, with an additional "source" field).
"""
return self.__api_request('GET', '/api/v1/accounts/verify_credentials')
@api_version("1.0.0", "2.1.0", __DICT_VERSION_ACCOUNT)
def me(self):
"""
Get this users account. Symonym for `account_verify_credentials()`, does exactly
the same thing, just exists becase `account_verify_credentials()` has a confusing
name.
"""
return self.account_verify_credentials()
###
# Internal helpers, dragons probably
###
@staticmethod
def __json_allow_dict_attrs(json_object):
"""
Makes it possible to use attribute notation to access a dicts
elements, while still allowing the dict to act as a dict.
"""
if isinstance(json_object, dict):
return AttribAccessDict(json_object)
return json_object
@staticmethod
def __json_date_parse(json_object):
"""
Parse dates in certain known json fields, if possible.
"""
known_date_fields = ["created_at", "week", "day", "expires_at", "scheduled_at", "updated_at", "last_status_at", "starts_at", "ends_at", "published_at"]
for k, v in json_object.items():
if k in known_date_fields:
if v != None:
try:
if isinstance(v, int):
json_object[k] = datetime.datetime.fromtimestamp(v, pytz.utc)
else:
json_object[k] = dateutil.parser.parse(v)
except:
raise AkkomaAPIError('Encountered invalid date.')
return json_object
@staticmethod
def __json_truefalse_parse(json_object):
"""
Parse 'True' / 'False' strings in certain known fields
"""
for key in ('follow', 'favourite', 'reblog', 'mention'):
if (key in json_object and isinstance(json_object[key], six.text_type)):
if json_object[key].lower() == 'true':
json_object[key] = True
if json_object[key].lower() == 'False':
json_object[key] = False
return json_object
@staticmethod
def __json_strnum_to_bignum(json_object):
"""
Converts json string numerals to native python bignums.
"""
for key in ('id', 'week', 'in_reply_to_id', 'in_reply_to_account_id', 'logins', 'registrations', 'statuses', 'day', 'last_read_id'):
if (key in json_object and isinstance(json_object[key], six.text_type)):
try:
json_object[key] = int(json_object[key])
except ValueError:
pass
return json_object
@staticmethod
def __json_hooks(json_object):
"""
All the json hooks. Used in request parsing.
"""
json_object = Akkoma.__json_strnum_to_bignum(json_object)
json_object = Akkoma.__json_date_parse(json_object)
json_object = Akkoma.__json_truefalse_parse(json_object)
json_object = Akkoma.__json_allow_dict_attrs(json_object)
return json_object
def __api_request(self, method, endpoint, params={}, files={}, headers={}, access_token_override=None, base_url_override=None, do_ratelimiting=True, use_json=False, parse=True):
"""
Internal API request helper.
"""
response = None
remaining_wait = 0
# "pace" mode ratelimiting: Assume constant rate of requests, sleep a little less long than it
# would take to not hit the rate limit at that request rate.
if do_ratelimiting and self.ratelimit_method == "pace":
if self.ratelimit_remaining == 0:
to_next = self.ratelimit_reset - time.time()
if to_next > 0:
# As a precaution, never sleep longer than 5 minutes
to_next = min(to_next, 5 * 60)
time.sleep(to_next)
else:
time_waited = time.time() - self.ratelimit_lastcall
time_wait = float(self.ratelimit_reset - time.time()) / float(self.ratelimit_remaining)
remaining_wait = time_wait - time_waited
if remaining_wait > 0:
to_next = remaining_wait / self.ratelimit_pacefactor
to_next = min(to_next, 5 * 60)
time.sleep(to_next)
# Generate request headers
headers = copy.deepcopy(headers)
if not self.access_token is None:
headers['Authorization'] = 'Bearer ' + self.access_token
if not access_token_override is None:
headers['Authorization'] = 'Bearer ' + access_token_override
# Determine base URL
base_url = self.api_base_url
if not base_url_override is None:
base_url = base_url_override
if self.debug_requests:
print('Akkoma: Request to endpoint "' + base_url + endpoint + '" using method "' + method + '".')
print('Parameters: ' + str(params))
print('Headers: ' + str(headers))
print('Files: ' + str(files))
# Make request
request_complete = False
while not request_complete:
request_complete = True
response_object = None
try:
kwargs = dict(headers=headers, files=files,
timeout=self.request_timeout)
if use_json == False:
if method == 'GET':
kwargs['params'] = params
else:
kwargs['data'] = params
else:
kwargs['json'] = params
# Block list with exactly three entries, matching on hashes of the instance API domain
# For more information, have a look at the docs
if hashlib.sha256(",".join(base_url.split("//")[-1].split("/")[0].split(".")[-2:]).encode("utf-8")).hexdigest() in \
[
"f3b50af8594eaa91dc440357a92691ff65dbfc9555226e9545b8e083dc10d2e1",
"b96d2de9784efb5af0af56965b8616afe5469c06e7188ad0ccaee5c7cb8a56b6",
"2dc0cbc89fad4873f665b78cc2f8b6b80fae4af9ac43c0d693edfda27275f517"
]:
raise Exception("Access denied.")
response_object = self.session.request(method, base_url + endpoint, **kwargs)
except Exception as e:
raise AkkomaNetworkError("Could not complete request: %s" % e)
if response_object is None:
raise AkkomaIllegalArgumentError("Illegal request.")
# Parse rate limiting headers
if 'X-RateLimit-Remaining' in response_object.headers and do_ratelimiting:
self.ratelimit_remaining = int(response_object.headers['X-RateLimit-Remaining'])
self.ratelimit_limit = int(response_object.headers['X-RateLimit-Limit'])
try:
ratelimit_reset_datetime = dateutil.parser.parse(response_object.headers['X-RateLimit-Reset'])
self.ratelimit_reset = self.__datetime_to_epoch(ratelimit_reset_datetime)
# Adjust server time to local clock
if 'Date' in response_object.headers:
server_time_datetime = dateutil.parser.parse(response_object.headers['Date'])
server_time = self.__datetime_to_epoch(server_time_datetime)
server_time_diff = time.time() - server_time
self.ratelimit_reset += server_time_diff
self.ratelimit_lastcall = time.time()
except Exception as e:
raise AkkomaRatelimitError("Rate limit time calculations failed: %s" % e)
# Handle response
if self.debug_requests:
print('Akkoma: Response received with code ' + str(response_object.status_code) + '.')
print('response headers: ' + str(response_object.headers))
print('Response text content: ' + str(response_object.text))
if not response_object.ok:
try:
response = response_object.json(object_hook=self.__json_hooks)
if isinstance(response, dict) and 'error' in response:
error_msg = response['error']
elif isinstance(response, str):
error_msg = response
else:
error_msg = None
except ValueError:
error_msg = None
# Handle rate limiting
if response_object.status_code == 429:
if self.ratelimit_method == 'throw' or not do_ratelimiting:
raise AkkomaRatelimitError('Hit rate limit.')
elif self.ratelimit_method in ('wait', 'pace'):
to_next = self.ratelimit_reset - time.time()
if to_next > 0:
# As a precaution, never sleep longer than 5 minutes
to_next = min(to_next, 5 * 60)
time.sleep(to_next)
request_complete = False
continue
if response_object.status_code == 404:
ex_type = AkkomaNotFoundError
if not error_msg:
error_msg = 'Endpoint not found.'
# this is for compatibility with older versions
# which raised AkkomaAPIError('Endpoint not found.')
# on any 404
elif response_object.status_code == 401:
ex_type = AkkomaUnauthorizedError
elif response_object.status_code == 500:
ex_type = AkkomaInternalServerError
elif response_object.status_code == 502:
ex_type = AkkomaBadGatewayError
elif response_object.status_code == 503:
ex_type = AkkomaServiceUnavailableError
elif response_object.status_code == 504:
ex_type = AkkomaGatewayTimeoutError
elif response_object.status_code >= 500 and \
response_object.status_code <= 511:
ex_type = AkkomaServerError
else:
ex_type = AkkomaAPIError
raise ex_type(
'Akkoma API returned error',
response_object.status_code,
response_object.reason,
error_msg)
if parse == True:
try:
response = response_object.json(object_hook=self.__json_hooks)
except:
raise AkkomaAPIError(
"Could not parse response as JSON, response code was %s, "
"bad json content was '%s'" % (response_object.status_code,
response_object.content))
else:
response = response_object.content
# Parse link headers
if isinstance(response, list) and \
'Link' in response_object.headers and \
response_object.headers['Link'] != "":
tmp_urls = requests.utils.parse_header_links(
response_object.headers['Link'].rstrip('>').replace('>,<', ',<'))
for url in tmp_urls:
if 'rel' not in url:
continue
if url['rel'] == 'next':
# Be paranoid and extract max_id specifically
next_url = url['url']
matchgroups = re.search(r"[?&]max_id=([^&]+)", next_url)
if matchgroups:
next_params = copy.deepcopy(params)
next_params['_pagination_method'] = method
next_params['_pagination_endpoint'] = endpoint
max_id = matchgroups.group(1)
if max_id.isdigit():
next_params['max_id'] = int(max_id)
else:
next_params['max_id'] = max_id
if "since_id" in next_params:
del next_params['since_id']
if "min_id" in next_params:
del next_params['min_id']
response[-1]._pagination_next = next_params
if url['rel'] == 'prev':
# Be paranoid and extract since_id or min_id specifically
prev_url = url['url']
# Old and busted (pre-2.6.0): since_id pagination
matchgroups = re.search(r"[?&]since_id=([^&]+)", prev_url)
if matchgroups:
prev_params = copy.deepcopy(params)
prev_params['_pagination_method'] = method
prev_params['_pagination_endpoint'] = endpoint
since_id = matchgroups.group(1)
if since_id.isdigit():
prev_params['since_id'] = int(since_id)
else:
prev_params['since_id'] = since_id
if "max_id" in prev_params:
del prev_params['max_id']
response[0]._pagination_prev = prev_params
# New and fantastico (post-2.6.0): min_id pagination
matchgroups = re.search(r"[?&]min_id=([^&]+)", prev_url)
if matchgroups:
prev_params = copy.deepcopy(params)
prev_params['_pagination_method'] = method
prev_params['_pagination_endpoint'] = endpoint
min_id = matchgroups.group(1)
if min_id.isdigit():
prev_params['min_id'] = int(min_id)
else:
prev_params['min_id'] = min_id
if "max_id" in prev_params:
del prev_params['max_id']
response[0]._pagination_prev = prev_params
return response
###
# Reading data: Apps
###
@api_version("2.0.0", "2.7.2", __DICT_VERSION_APPLICATION)
def app_verify_credentials(self):
"""
Fetch information about the current application.
Returns an `application dict`_.
"""
return self.__api_request('GET', '/api/v1/apps/verify_credentials')
def __generate_params(self, params, exclude=[]):
"""
Internal named-parameters-to-dict helper.
Note for developers: If called with locals() as params,
as is the usual practice in this code, the __generate_params call
(or at least the locals() call) should generally be the first thing
in your function.
"""
params = collections.OrderedDict(params)
if 'self' in params:
del params['self']
param_keys = list(params.keys())
for key in param_keys:
if isinstance(params[key], bool) and params[key] == False:
params[key] = '0'
if isinstance(params[key], bool) and params[key] == True:
params[key] = '1'
for key in param_keys:
if params[key] is None or key in exclude:
del params[key]
param_keys = list(params.keys())
for key in param_keys:
if isinstance(params[key], list):
params[key + "[]"] = params[key]
del params[key]
return params
###
# Writing data: Statuses
###
@api_version("1.0.0", "2.8.0", __DICT_VERSION_STATUS)
def status_post(self, status, in_reply_to_id=None, media_ids=None,
sensitive=False, visibility=None, spoiler_text=None,
language=None, idempotency_key=None, content_type=None,
scheduled_at=None, poll=None, quote_id=None):
"""
Post a status. Can optionally be in reply to another status and contain
media.
`media_ids` should be a list. (If it's not, the function will turn it
into one.) It can contain up to four pieces of media (uploaded via
`media_post()`_). `media_ids` can also be the `media dicts`_ returned
by `media_post()`_ - they are unpacked automatically.
The `sensitive` boolean decides whether or not media attached to the post
should be marked as sensitive, which hides it by default on the Mastodon
web front-end.
The visibility parameter is a string value and accepts any of:
'direct' - post will be visible only to mentioned users
'private' - post will be visible only to followers
'unlisted' - post will be public but not appear on the public timeline
'public' - post will be public
If not passed in, visibility defaults to match the current account's
default-privacy setting (starting with Mastodon version 1.6) or its
locked setting - private if the account is locked, public otherwise
(for Mastodon versions lower than 1.6).
The `spoiler_text` parameter is a string to be shown as a warning before
the text of the status. If no text is passed in, no warning will be
displayed.
Specify `language` to override automatic language detection. The parameter
accepts all valid ISO 639-2 language codes.
You can set `idempotency_key` to a value to uniquely identify an attempt
at posting a status. Even if you call this function more than once,
if you call it with the same `idempotency_key`, only one status will
be created.
Pass a datetime as `scheduled_at` to schedule the toot for a specific time
(the time must be at least 5 minutes into the future). If this is passed,
status_post returns a `scheduled toot dict`_ instead.
Pass `poll` to attach a poll to the status. An appropriate object can be
constructed using `make_poll()`_ . Note that as of Mastodon version
2.8.2, you can only have either media or a poll attached, not both at
the same time.
**Specific to `pleroma` feature set:**: Specify `content_type` to set
the content type of your post on Pleroma. It accepts 'text/plain' (default),
'text/markdown', 'text/html' and 'text/bbcode. This parameter is not
supported on Mastodon servers, but will be safely ignored if set.
**Specific to `fedibird` feature set:**: The `quote_id` parameter is
a non-standard extension that specifies the id of a quoted status.
Returns a `toot dict`_ with the new status.
"""
if quote_id != None:
if self.feature_set != "fedibird":
raise MastodonIllegalArgumentError('quote_id is only available with feature set fedibird')
quote_id = self.__unpack_id(quote_id)
if content_type != None:
if self.feature_set != "pleroma":
raise MastodonIllegalArgumentError('quote_id is only available with feature set pleroma')
# It would be better to read this from nodeinfo and cache, but this is easier
if not content_type in ["text/plain", "text/html", "text/markdown", "text/bbcode"]:
raise MastodonIllegalArgumentError('Invalid content type specified')
if in_reply_to_id != None:
in_reply_to_id = self.__unpack_id(in_reply_to_id)
if scheduled_at != None:
scheduled_at = self.__consistent_isoformat_utc(scheduled_at)
params_initial = locals()
# Validate poll/media exclusivity
if not poll is None:
if (not media_ids is None) and len(media_ids) != 0:
raise ValueError('Status can have media or poll attached - not both.')
# Validate visibility parameter
valid_visibilities = ['private', 'public', 'unlisted', 'direct']
if params_initial['visibility'] == None:
del params_initial['visibility']
else:
params_initial['visibility'] = params_initial['visibility'].lower()
if params_initial['visibility'] not in valid_visibilities:
raise ValueError('Invalid visibility value! Acceptable '
'values are %s' % valid_visibilities)
if params_initial['language'] == None:
del params_initial['language']
if params_initial['sensitive'] is False:
del [params_initial['sensitive']]
headers = {}
if idempotency_key != None:
headers['Idempotency-Key'] = idempotency_key
if media_ids is not None:
try:
media_ids_proper = []
if not isinstance(media_ids, (list, tuple)):
media_ids = [media_ids]
for media_id in media_ids:
if isinstance(media_id, dict):
media_ids_proper.append(media_id["id"])
else:
media_ids_proper.append(media_id)
except Exception as e:
raise MastodonIllegalArgumentError("Invalid media "
"dict: %s" % e)
params_initial["media_ids"] = media_ids_proper
if params_initial['content_type'] == None:
del params_initial['content_type']
use_json = False
if not poll is None:
use_json = True
params = self.__generate_params(params_initial, ['idempotency_key'])
return self.__api_request('POST', '/api/v1/statuses', params, headers = headers, use_json = use_json)
###
# Writing data: Notifications
###
#@api_version("1.0.0", "1.0.0", "1.0.0")
def notifications_clear(self):
"""
Clear out a users notifications
"""
self.__api_request('POST', '/api/v1/notifications/clear')
#@api_version("1.3.0", "2.9.2", "2.9.2")
def notifications_dismiss(self, id):
"""
Deletes a single notification
"""
id = self.__unpack_id(id)
url = '/api/v1/notifications/{0}/dismiss'.format(str(id))
self.__api_request('POST', url)
###
# Writing data: Media
###
@api_version("1.0.0", "2.9.1", __DICT_VERSION_MEDIA)
def media_post(self, media_file, mime_type=None, description=None, focus=None):
"""
Post an image, video or audio file. `media_file` can either be image data or
a file name. If image data is passed directly, the mime
type has to be specified manually, otherwise, it is
determined from the file name. `focus` should be a tuple
of floats between -1 and 1, giving the x and y coordinates
of the images focus point for cropping (with the origin being the images
center).
Throws a `AkkomaIllegalArgumentError` if the mime type of the
passed data or file can not be determined properly.
Returns a `media dict`_. This contains the id that can be used in
status_post to attach the media file to a toot.
"""
if mime_type is None and (isinstance(media_file, str) and os.path.isfile(media_file)):
mime_type = guess_type(media_file)
media_file = open(media_file, 'rb')
elif isinstance(media_file, str) and os.path.isfile(media_file):
media_file = open(media_file, 'rb')
if mime_type is None:
raise AkkomaIllegalArgumentError('Could not determine mime type'
' or data passed directly '
'without mime type.')
random_suffix = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(10))
file_name = "akkomapy_upload_" + str(time.time()) + "_" + str(random_suffix) + mimetypes.guess_extension(mime_type)
if focus != None:
focus = str(focus[0]) + "," + str(focus[1])
media_file_description = (file_name, media_file, mime_type)
return self.__api_request('POST', '/api/v1/media',
files={'file': media_file_description},
params={'description': description, 'focus': focus})
def __unpack_id(self, id):
"""
Internal object-to-id converter
Checks if id is a dict that contains id and
returns the id inside, otherwise just returns
the id straight.
"""
if isinstance(id, dict) and "id" in id:
return id["id"]
else:
return id
def __set_token_expired(self, value):
"""Internal helper for oauth code"""
self._token_expired = datetime.datetime.now() + datetime.timedelta(seconds=value)
return
def __set_refresh_token(self, value):
"""Internal helper for oauth code"""
self._refresh_token = value
return
@staticmethod
def __protocolize(base_url):
"""Internal add-protocol-to-url helper"""
if not base_url.startswith("http://") and not base_url.startswith("https://"):
base_url = "https://" + base_url
# Some API endpoints can't handle extra /'s in path requests
base_url = base_url.rstrip("/")
return base_url
##
# Exceptions
##
class AkkomaError(Exception):
"""Base class for Akkoma.py exceptions"""
class AkkomaVersionError(AkkomaError):
"""Raised when a function is called that the version of Akkoma for which
Akkoma.py was instantiated does not support"""
class AkkomaIllegalArgumentError(ValueError, AkkomaError):
"""Raised when an incorrect parameter is passed to a function"""
pass
class AkkomaIOError(IOError, AkkomaError):
"""Base class for Akkoma.py I/O errors"""
class AkkomaNetworkError(AkkomaIOError):
"""Raised when network communication with the server fails"""
pass
class AkkomaReadTimeout(AkkomaNetworkError):
"""Raised when a stream times out"""
pass
class AkkomaAPIError(AkkomaError):
"""Raised when the akkoma API generates a response that cannot be handled"""
pass
class AkkomaNotFoundError(AkkomaAPIError):
"""Raised when the akkoma API returns a 404 Not Found error"""
pass
class AkkomaMalformedEventError(AkkomaError):
"""Raised when the server-sent event stream is malformed"""
pass