2016-11-23 23:30:51 +01:00
|
|
|
# coding: utf-8
|
|
|
|
|
|
|
|
import requests
|
|
|
|
import os
|
|
|
|
import os.path
|
2016-11-24 12:34:31 +01:00
|
|
|
import mimetypes
|
|
|
|
import time
|
|
|
|
import random
|
|
|
|
import string
|
2016-11-23 23:30:51 +01:00
|
|
|
|
|
|
|
class Mastodon:
|
2016-11-24 00:36:00 +01:00
|
|
|
"""
|
2016-11-24 00:55:09 +01:00
|
|
|
Super basic but thorough and easy to use mastodon.social
|
2016-11-24 00:36:00 +01:00
|
|
|
api wrapper in python.
|
2016-11-23 23:30:51 +01:00
|
|
|
|
2016-11-24 00:36:00 +01:00
|
|
|
If anything is unclear, check the official API docs at
|
|
|
|
https://github.com/Gargron/mastodon/wiki/API
|
2016-11-23 23:30:51 +01:00
|
|
|
|
2016-11-24 00:36:00 +01:00
|
|
|
Presently, only username-password login is supported, somebody please
|
|
|
|
patch in Real Proper OAuth if desired.
|
2016-11-23 23:30:51 +01:00
|
|
|
|
2016-11-24 00:36:00 +01:00
|
|
|
KNOWN BUGS: Media api does not work, reason unclear.
|
2016-11-23 23:30:51 +01:00
|
|
|
"""
|
|
|
|
__DEFAULT_BASE_URL = 'https://mastodon.social'
|
|
|
|
|
|
|
|
###
|
|
|
|
# Registering apps
|
|
|
|
###
|
|
|
|
@staticmethod
|
|
|
|
def create_app(client_name, scopes = ['read', 'write', 'follow'], redirect_uris = None, to_file = None, api_base_url = __DEFAULT_BASE_URL):
|
2016-11-24 00:36:00 +01:00
|
|
|
"""
|
2016-11-24 00:55:09 +01:00
|
|
|
Creates a new app with given client_name and scopes (read, write, follow)
|
2016-11-23 23:30:51 +01:00
|
|
|
|
2016-11-24 00:36:00 +01:00
|
|
|
Specify redirect_uris if you want users to be redirected to a certain page after authenticating.
|
|
|
|
Specify to_file to persist your apps info to a file so you can use them in the constructor.
|
|
|
|
Specify api_base_url if you want to register an app on an instance different from the flagship one.
|
2016-11-23 23:30:51 +01:00
|
|
|
|
2016-11-24 00:36:00 +01:00
|
|
|
Returns client_id and client_secret.
|
2016-11-23 23:30:51 +01:00
|
|
|
"""
|
|
|
|
request_data = {
|
|
|
|
'client_name': client_name,
|
|
|
|
'scopes': " ".join(scopes)
|
|
|
|
}
|
|
|
|
|
|
|
|
if redirect_uris != None:
|
|
|
|
request_data['redirect_uris'] = redirect_uris;
|
|
|
|
else:
|
|
|
|
request_data['redirect_uris'] = 'urn:ietf:wg:oauth:2.0:oob';
|
|
|
|
|
|
|
|
response = requests.post(api_base_url + '/api/v1/apps', data = request_data).json()
|
|
|
|
|
|
|
|
if to_file != None:
|
|
|
|
with open(to_file, 'w') as secret_file:
|
|
|
|
secret_file.write(response['client_id'] + '\n')
|
|
|
|
secret_file.write(response['client_secret'] + '\n')
|
|
|
|
|
|
|
|
return (response['client_id'], response['client_secret'])
|
|
|
|
|
|
|
|
###
|
|
|
|
# Authentication, including constructor
|
|
|
|
###
|
|
|
|
def __init__(self, client_id, client_secret = None, access_token = None, api_base_url = __DEFAULT_BASE_URL):
|
2016-11-24 00:36:00 +01:00
|
|
|
"""
|
2016-11-24 00:55:09 +01:00
|
|
|
Creates a new API wrapper instance based on the given client_secret and client_id. If you
|
2016-11-24 00:36:00 +01:00
|
|
|
give a client_id and it is not a file, you must also give a secret.
|
2016-11-23 23:30:51 +01:00
|
|
|
|
2016-11-24 00:36:00 +01:00
|
|
|
You can also directly specify an access_token, directly or as a file.
|
2016-11-23 23:30:51 +01:00
|
|
|
|
2016-11-24 00:36:00 +01:00
|
|
|
Specify api_base_url if you wish to talk to an instance other than the flagship one.
|
|
|
|
If a file is given as client_id, read client ID and secret from that file
|
2016-11-23 23:30:51 +01:00
|
|
|
"""
|
|
|
|
self.api_base_url = api_base_url
|
|
|
|
self.client_id = client_id
|
|
|
|
self.client_secret = client_secret
|
|
|
|
self.access_token = access_token
|
|
|
|
|
|
|
|
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()
|
|
|
|
else:
|
|
|
|
if self.client_secret == None:
|
|
|
|
raise ValueError('Specified client id directly, but did not supply secret')
|
|
|
|
|
|
|
|
if self.access_token != None and os.path.isfile(self.access_token):
|
|
|
|
with open(self.access_token, 'r') as token_file:
|
|
|
|
self.access_token = token_file.readline().rstrip()
|
|
|
|
|
|
|
|
def log_in(self, username, password, scopes = ['read', 'write', 'follow'], to_file = None):
|
2016-11-24 00:36:00 +01:00
|
|
|
"""
|
2016-11-24 00:55:09 +01:00
|
|
|
Logs in and sets access_token to what was returned.
|
2016-11-24 00:36:00 +01:00
|
|
|
Can persist access token to file.
|
2016-11-23 23:30:51 +01:00
|
|
|
|
2016-11-24 12:08:32 +01:00
|
|
|
Will throw an exception if username / password are wrong, scopes are not
|
|
|
|
valid or granted scopes differ from requested.
|
|
|
|
|
2016-11-24 00:36:00 +01:00
|
|
|
Returns the access_token, as well.
|
2016-11-23 23:30:51 +01:00
|
|
|
"""
|
|
|
|
params = self.__generate_params(locals())
|
|
|
|
params['client_id'] = self.client_id
|
|
|
|
params['client_secret'] = self.client_secret
|
|
|
|
params['grant_type'] = 'password'
|
|
|
|
params['scope'] = " ".join(scopes)
|
|
|
|
|
2016-11-24 12:03:42 +01:00
|
|
|
try:
|
|
|
|
response = self.__api_request('POST', '/oauth/token', params)
|
|
|
|
self.access_token = response['access_token']
|
|
|
|
except:
|
|
|
|
raise ValueError('Invalid user name, password or scopes.')
|
|
|
|
|
2016-11-24 12:07:41 +01:00
|
|
|
requested_scopes = " ".join(sorted(scopes))
|
|
|
|
received_scopes = " ".join(sorted(response["scope"].split(" ")))
|
2016-11-24 12:03:42 +01:00
|
|
|
|
|
|
|
if requested_scopes != received_scopes:
|
|
|
|
raise ValueError('Granted scopes "' + received_scopes + '" differ from requested scopes "' + requested_scopes + '".')
|
2016-11-23 23:30:51 +01:00
|
|
|
|
|
|
|
if to_file != None:
|
|
|
|
with open(to_file, 'w') as token_file:
|
|
|
|
token_file.write(response['access_token'] + '\n')
|
|
|
|
|
|
|
|
return response['access_token']
|
|
|
|
|
|
|
|
###
|
|
|
|
# Reading data: Timelines
|
|
|
|
##
|
|
|
|
def timeline(self, timeline = 'home', max_id = None, since_id = None, limit = None):
|
2016-11-24 00:36:00 +01:00
|
|
|
"""
|
2016-11-24 00:55:09 +01:00
|
|
|
Returns statuses, most recent ones first. Timeline can be home, mentions, public
|
2016-11-24 00:36:00 +01:00
|
|
|
or tag/:hashtag
|
2016-11-24 00:55:09 +01:00
|
|
|
"""
|
2016-11-23 23:30:51 +01:00
|
|
|
params = self.__generate_params(locals(), ['timeline'])
|
|
|
|
return self.__api_request('GET', '/api/v1/timelines/' + timeline, params)
|
|
|
|
|
|
|
|
###
|
|
|
|
# Reading data: Statuses
|
|
|
|
###
|
|
|
|
def status(self, id):
|
2016-11-24 00:36:00 +01:00
|
|
|
"""
|
2016-11-24 00:55:09 +01:00
|
|
|
Returns a status.
|
|
|
|
"""
|
2016-11-23 23:30:51 +01:00
|
|
|
return self.__api_request('GET', '/api/v1/statuses/' + str(id))
|
|
|
|
|
|
|
|
def status_context(self, id):
|
2016-11-24 00:36:00 +01:00
|
|
|
"""
|
2016-11-24 00:55:09 +01:00
|
|
|
Returns ancestors and descendants of the status.
|
|
|
|
"""
|
2016-11-23 23:30:51 +01:00
|
|
|
return self.__api_request('GET', '/api/v1/statuses/' + str(id) + '/context')
|
|
|
|
|
|
|
|
def status_reblogged_by(self, id):
|
2016-11-24 00:36:00 +01:00
|
|
|
"""
|
2016-11-24 00:55:09 +01:00
|
|
|
Returns a list of users that have reblogged a status.
|
|
|
|
"""
|
2016-11-23 23:30:51 +01:00
|
|
|
return self.__api_request('GET', '/api/v1/statuses/' + str(id) + '/reblogged_by')
|
|
|
|
|
|
|
|
def status_favourited_by(self, id):
|
2016-11-24 00:36:00 +01:00
|
|
|
"""
|
2016-11-24 00:55:09 +01:00
|
|
|
Returns a list of users that have favourited a status.
|
|
|
|
"""
|
2016-11-23 23:30:51 +01:00
|
|
|
return self.__api_request('GET', '/api/v1/statuses/' + str(id) + '/favourited_by')
|
|
|
|
|
|
|
|
###
|
|
|
|
# Reading data: Accounts
|
|
|
|
###
|
|
|
|
def account(self, id):
|
2016-11-24 00:36:00 +01:00
|
|
|
"""
|
2016-11-24 00:55:09 +01:00
|
|
|
Returns account.
|
|
|
|
"""
|
2016-11-23 23:30:51 +01:00
|
|
|
return self.__api_request('GET', '/api/v1/accounts/' + str(id))
|
|
|
|
|
|
|
|
def account_verify_credentials(self):
|
2016-11-24 00:36:00 +01:00
|
|
|
"""
|
2016-11-24 00:55:09 +01:00
|
|
|
Returns authenticated user's account.
|
|
|
|
"""
|
2016-11-23 23:30:51 +01:00
|
|
|
return self.__api_request('GET', '/api/v1/accounts/verify_credentials')
|
|
|
|
|
|
|
|
def account_statuses(self, id, max_id = None, since_id = None, limit = None):
|
2016-11-24 00:36:00 +01:00
|
|
|
"""
|
2016-11-24 00:55:09 +01:00
|
|
|
Returns statuses by user. Same options as timeline are permitted.
|
|
|
|
"""
|
2016-11-23 23:30:51 +01:00
|
|
|
params = self.__generate_params(locals(), ['id'])
|
|
|
|
return self.__api_request('GET', '/api/v1/accounts/' + str(id) + '/statuses')
|
|
|
|
|
|
|
|
def account_following(self, id):
|
2016-11-24 00:36:00 +01:00
|
|
|
"""
|
2016-11-24 00:55:09 +01:00
|
|
|
Returns users the given user is following.
|
|
|
|
"""
|
2016-11-23 23:30:51 +01:00
|
|
|
return self.__api_request('GET', '/api/v1/accounts/' + str(id) + '/following')
|
|
|
|
|
|
|
|
def account_followers(self, id):
|
2016-11-24 00:36:00 +01:00
|
|
|
"""
|
2016-11-24 00:55:09 +01:00
|
|
|
Returns users the given user is followed by.
|
|
|
|
"""
|
2016-11-23 23:30:51 +01:00
|
|
|
return self.__api_request('GET', '/api/v1/accounts/' + str(id) + '/followers')
|
|
|
|
|
|
|
|
def account_relationships(self, id):
|
2016-11-24 00:36:00 +01:00
|
|
|
"""
|
2016-11-24 00:55:09 +01:00
|
|
|
Returns relationships (following, followed_by, blocking) of the logged in user to
|
2016-11-24 00:36:00 +01:00
|
|
|
a given account. id can be a list.
|
2016-11-24 00:55:09 +01:00
|
|
|
"""
|
2016-11-23 23:30:51 +01:00
|
|
|
params = self.__generate_params(locals())
|
|
|
|
return self.__api_request('GET', '/api/v1/accounts/relationships', params)
|
|
|
|
|
|
|
|
def account_suggestions(self):
|
2016-11-24 00:36:00 +01:00
|
|
|
"""
|
2016-11-24 00:55:09 +01:00
|
|
|
Returns accounts that the system suggests the authenticated user to follow.
|
|
|
|
"""
|
2016-11-23 23:30:51 +01:00
|
|
|
return self.__api_request('GET', '/api/v1/accounts/suggestions')
|
|
|
|
|
|
|
|
def account_search(self, q, limit = None):
|
2016-11-24 00:36:00 +01:00
|
|
|
"""
|
2016-11-24 00:55:09 +01:00
|
|
|
Returns matching accounts. Will lookup an account remotely if the search term is
|
2016-11-24 00:36:00 +01:00
|
|
|
in the username@domain format and not yet in the database.
|
2016-11-24 00:55:09 +01:00
|
|
|
"""
|
2016-11-23 23:30:51 +01:00
|
|
|
params = self.__generate_params(locals())
|
|
|
|
return self.__api_request('GET', '/api/v1/accounts/search', params)
|
|
|
|
|
|
|
|
###
|
|
|
|
# Writing data: Statuses
|
|
|
|
###
|
|
|
|
def status_post(self, status, in_reply_to_id = None, media_ids = None):
|
2016-11-24 00:36:00 +01:00
|
|
|
"""
|
2016-11-24 00:55:09 +01:00
|
|
|
Posts a status. Can optionally be in reply to another status and contain
|
2016-11-24 00:36:00 +01:00
|
|
|
up to four pieces of media (Uploaded via media_post()).
|
2016-11-23 23:30:51 +01:00
|
|
|
|
2016-11-24 00:36:00 +01:00
|
|
|
Returns the new status.
|
2016-11-24 00:55:09 +01:00
|
|
|
"""
|
2016-11-23 23:30:51 +01:00
|
|
|
params = self.__generate_params(locals())
|
|
|
|
return self.__api_request('POST', '/api/v1/statuses', params)
|
|
|
|
|
|
|
|
def toot(self, status):
|
2016-11-24 00:36:00 +01:00
|
|
|
"""
|
2016-11-24 00:55:09 +01:00
|
|
|
Synonym for status_post that only takes the status text as input.
|
|
|
|
"""
|
2016-11-23 23:30:51 +01:00
|
|
|
return self.status_post(status)
|
|
|
|
|
|
|
|
def status_delete(self, id):
|
2016-11-24 00:36:00 +01:00
|
|
|
"""
|
2016-11-24 00:55:09 +01:00
|
|
|
Deletes a status
|
|
|
|
"""
|
2016-11-23 23:30:51 +01:00
|
|
|
return self.__api_request('DELETE', '/api/v1/statuses/' + str(id))
|
|
|
|
|
|
|
|
def status_reblog(self, id):
|
|
|
|
"""Reblogs a status.
|
|
|
|
|
|
|
|
Returns a new status that wraps around the reblogged one."""
|
|
|
|
return self.__api_request('POST', '/api/v1/statuses/' + str(id) + "/reblog")
|
|
|
|
|
|
|
|
def status_unreblog(self, id):
|
2016-11-24 00:36:00 +01:00
|
|
|
"""
|
2016-11-24 00:55:09 +01:00
|
|
|
Un-reblogs a status.
|
2016-11-23 23:30:51 +01:00
|
|
|
|
2016-11-24 00:36:00 +01:00
|
|
|
Returns the status that used to be reblogged.
|
2016-11-24 00:55:09 +01:00
|
|
|
"""
|
2016-11-23 23:30:51 +01:00
|
|
|
return self.__api_request('POST', '/api/v1/statuses/' + str(id) + "/unreblog")
|
|
|
|
|
|
|
|
def status_favourite(self, id):
|
2016-11-24 00:36:00 +01:00
|
|
|
"""
|
2016-11-24 00:55:09 +01:00
|
|
|
Favourites a status.
|
2016-11-23 23:30:51 +01:00
|
|
|
|
2016-11-24 00:36:00 +01:00
|
|
|
Returns the favourited status.
|
2016-11-24 00:55:09 +01:00
|
|
|
"""
|
2016-11-23 23:30:51 +01:00
|
|
|
return self.__api_request('POST', '/api/v1/statuses/' + str(id) + "/favourite")
|
|
|
|
|
|
|
|
def status_unfavourite(self, id):
|
|
|
|
"""Favourites a status.
|
|
|
|
|
2016-11-24 00:36:00 +01:00
|
|
|
Returns the un-favourited status.
|
2016-11-24 00:55:09 +01:00
|
|
|
"""
|
2016-11-23 23:30:51 +01:00
|
|
|
return self.__api_request('POST', '/api/v1/statuses/' + str(id) + "/unfavourite")
|
|
|
|
|
|
|
|
###
|
2016-11-24 03:03:52 +01:00
|
|
|
# Writing data: Accounts
|
2016-11-23 23:30:51 +01:00
|
|
|
###
|
|
|
|
def account_follow(self, id):
|
2016-11-24 00:36:00 +01:00
|
|
|
"""
|
2016-11-24 00:55:09 +01:00
|
|
|
Follows a user.
|
2016-11-23 23:30:51 +01:00
|
|
|
|
2016-11-24 00:36:00 +01:00
|
|
|
Returns the updated relationship to the user.
|
2016-11-24 00:55:09 +01:00
|
|
|
"""
|
2016-11-23 23:30:51 +01:00
|
|
|
return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/follow")
|
|
|
|
|
|
|
|
def account_unfollow(self, id):
|
2016-11-24 00:36:00 +01:00
|
|
|
"""
|
2016-11-24 00:55:09 +01:00
|
|
|
Unfollows a user.
|
2016-11-23 23:30:51 +01:00
|
|
|
|
2016-11-24 00:36:00 +01:00
|
|
|
Returns the updated relationship to the user.
|
2016-11-24 00:55:09 +01:00
|
|
|
"""
|
2016-11-23 23:30:51 +01:00
|
|
|
return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/unfollow")
|
|
|
|
|
|
|
|
def account_block(self, id):
|
2016-11-24 00:36:00 +01:00
|
|
|
"""
|
2016-11-24 00:55:09 +01:00
|
|
|
Blocks a user.
|
2016-11-23 23:30:51 +01:00
|
|
|
|
2016-11-24 00:36:00 +01:00
|
|
|
Returns the updated relationship to the user.
|
2016-11-24 00:55:09 +01:00
|
|
|
"""
|
2016-11-23 23:30:51 +01:00
|
|
|
return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/block")
|
|
|
|
|
|
|
|
def account_unblock(self, id):
|
2016-11-24 00:36:00 +01:00
|
|
|
"""
|
2016-11-24 00:55:09 +01:00
|
|
|
Unblocks a user.
|
2016-11-23 23:30:51 +01:00
|
|
|
|
2016-11-24 00:36:00 +01:00
|
|
|
Returns the updated relationship to the user.
|
2016-11-24 00:55:09 +01:00
|
|
|
"""
|
2016-11-23 23:30:51 +01:00
|
|
|
return self.__api_request('POST', '/api/v1/accounts/' + str(id) + "/unblock")
|
|
|
|
|
|
|
|
###
|
|
|
|
# Writing data: Media
|
|
|
|
###
|
2016-11-24 12:34:31 +01:00
|
|
|
def media_post(self, media_file, mime_type = None):
|
2016-11-24 00:36:00 +01:00
|
|
|
"""
|
2016-11-24 00:55:09 +01:00
|
|
|
Posts an image. media_file can either be image data or
|
2016-11-24 12:34:31 +01:00
|
|
|
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.
|
|
|
|
|
2016-11-24 00:36:00 +01:00
|
|
|
Returns the ID of the media that can then be used in status_post().
|
2016-11-24 12:34:31 +01:00
|
|
|
|
|
|
|
Throws a ValueError if the mime type of the passed data or file can
|
|
|
|
not be determined properly.
|
2016-11-24 00:55:09 +01:00
|
|
|
"""
|
2016-11-24 12:34:31 +01:00
|
|
|
|
2016-11-23 23:30:51 +01:00
|
|
|
if os.path.isfile(media_file):
|
2016-11-24 12:34:31 +01:00
|
|
|
mime_type = mimetypes.guess_type(media_file)[0]
|
2016-11-23 23:30:51 +01:00
|
|
|
media_file = open(media_file, 'rb')
|
2016-11-24 12:34:31 +01:00
|
|
|
|
|
|
|
if mime_type == None:
|
|
|
|
raise ValueError('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 = "mastodonpyupload_" + str(time.time()) + "_" + str(random_suffix) + mimetypes.guess_extension(mime_type)
|
|
|
|
|
|
|
|
media_file_description = (file_name, media_file, mime_type)
|
|
|
|
return self.__api_request('POST', '/api/v1/media', files = {'file': media_file_description})
|
2016-11-23 23:30:51 +01:00
|
|
|
|
|
|
|
###
|
|
|
|
# Internal helpers, dragons probably
|
|
|
|
###
|
|
|
|
def __api_request(self, method, endpoint, params = {}, files = {}):
|
2016-11-24 00:36:00 +01:00
|
|
|
"""
|
2016-11-24 00:55:09 +01:00
|
|
|
Internal API request helper.
|
|
|
|
"""
|
2016-11-23 23:30:51 +01:00
|
|
|
response = None
|
|
|
|
headers = None
|
|
|
|
|
|
|
|
if self.access_token != None:
|
|
|
|
headers = {'Authorization': 'Bearer ' + self.access_token}
|
|
|
|
|
|
|
|
if method == 'GET':
|
|
|
|
response = requests.get(self.api_base_url + endpoint, data = params, headers = headers, files = files)
|
|
|
|
|
|
|
|
if method == 'POST':
|
|
|
|
response = requests.post(self.api_base_url + endpoint, data = params, headers = headers, files = files)
|
|
|
|
|
|
|
|
if method == 'DELETE':
|
|
|
|
response = requests.delete(self.api_base_url + endpoint, data = params, headers = headers, files = files)
|
|
|
|
|
|
|
|
if response.status_code == 404:
|
|
|
|
raise IOError('Endpoint not found.')
|
|
|
|
|
|
|
|
if response.status_code == 500:
|
|
|
|
raise IOError('General API problem.')
|
|
|
|
|
2016-11-24 12:03:42 +01:00
|
|
|
try:
|
|
|
|
response = response.json()
|
|
|
|
except:
|
|
|
|
raise ValueError("Could not parse response as JSON, respose code was " + str(response.status_code))
|
|
|
|
|
|
|
|
return response
|
2016-11-23 23:30:51 +01:00
|
|
|
|
|
|
|
def __generate_params(self, params, exclude = []):
|
2016-11-24 00:36:00 +01:00
|
|
|
"""
|
2016-11-24 00:55:09 +01:00
|
|
|
Internal named-parameters-to-dict helper.
|
|
|
|
"""
|
2016-11-23 23:30:51 +01:00
|
|
|
params = dict(params)
|
|
|
|
|
|
|
|
del params['self']
|
|
|
|
param_keys = list(params.keys())
|
|
|
|
for key in param_keys:
|
|
|
|
if params[key] == 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
|