Clone Twitter posts to Mastodon. This allows you to follow any Twitter account
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.

200 lines
7.1 KiB

#! /usr/bin/python3
# -*- coding: utf-8 -*-
Copyright (C) 2017 Tuxicoman
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see <>.
import tweepy
from mastodon import Mastodon
import configparser
import os, os.path
import argparse
from getpass import getpass
import mimetypes
import urllib.request
import html
def register():
"""Generate config file"""
config_file_path = os.path.abspath(os.path.join(os.getcwd(), "twitter2mastodon.cfg"))
print('Mastodon application credentials will be generated and stored in %s. Your password will not be stored.' % config_file_path)
config = configparser.RawConfigParser()
section = 'mastodon'
config.set(section, "instance", "")
config.set(section, "client_id", "")
config.set(section, "client_secret", "")
config.set(section, "access_token", "")
section = 'twitter'
config.set(section, "consumer_key", "")
config.set(section, "consumer_secret", "")
config.set(section, "access_token", "")
config.set(section, "access_token_secret", "")
config.set(section, "screen_name", "")
config.set(section, "last_id", "")
# get the instance
instance = ""
while not instance:
instance = input('Mastodon instance URL (ex: ""): ')
if instance:
instance = "https://" + instance
# get the username
userok = False
while not userok:
user = input('Mastodon login (email) : ')
if not user:
print('Your Mastodon username can not be empty.')
userok = False
elif '@' not in user or '.' not in user:
print('Your Mastodon username should be an email.')
userok = False
userok = True
# get the password
password = getpass(prompt='Mastodon password: ')
print("Registering application...")
client_id, client_secret = Mastodon.create_app('twitter2mastodon', api_base_url=instance)
access_token = Mastodon(api_base_url=instance, client_id=client_id, client_secret=client_secret).log_in(username=user, password=password)
del user, password
print ("... OK")
#Save config
section = 'mastodon'
config.set(section, "instance", instance)
config.set(section, "client_id", client_id)
config.set(section, "client_secret", client_secret)
config.set(section, "access_token", access_token)
with open(config_file_path, 'w') as configfile:
print ("Saved Mastodon credentials in %s" % config_file_path)
print ("Now, provide your twitter application credentials.")
consumer_key = input('Twitter consumer key: ')
consumer_secret = input('Twitter consumer secret: ')
access_token = input('Twitter access token: ')
access_token_secret = input('Twitter access token secret: ')
screen_name = input('Twitter user screen name to follow: ')
#Save config
section = 'twitter'
config.set(section, "consumer_key", consumer_key)
config.set(section, "consumer_secret", consumer_secret)
config.set(section, "access_token", access_token)
config.set(section, "access_token_secret", access_token_secret)
config.set(section, "screen_name", screen_name)
with open(config_file_path, 'w') as configfile:
print ("Saved Twitter credentials in %s" % config_file_path)
print ("Now relaunch the program this way:\n$ python3 --config %s" % config_file_path)
def post(config_file_path,dry_run=False):
"""Copy new posts from Twitter to Mastodon"""
config = configparser.ConfigParser()
#Set up Mastodon API
mstdn = Mastodon(api_base_url=config.get("mastodon", "instance"), client_id=config.get("mastodon", "client_id"), client_secret=config.get("mastodon", "client_secret"), access_token=config.get("mastodon", "access_token") )
#Set up Twitter API
auth = tweepy.auth.OAuthHandler(config.get("twitter", "consumer_key"), config.get("twitter", "consumer_secret")) = True
auth.set_access_token(config.get("twitter", "access_token"), config.get("twitter", "access_token_secret"))
api = tweepy.API(auth, wait_on_rate_limit=True, wait_on_rate_limit_notify=True)
#Start from last post processed
last_id = config.get("twitter", "last_id")
if not last_id:
last_id = None
last_id = int(last_id)
new_id = 0
screen_name=config.get("twitter", "screen_name")
tweets = []
for tweet in tweepy.Cursor(api.user_timeline, screen_name=screen_name, since_id=last_id, tweet_mode="extended").items(100):
if > new_id:
#Keep the id of the most recent post
new_id =
if == last_id:
#Stop if we have processed this post already
# Filter to get only public posts
if tweet.in_reply_to_status_id is not None and tweet.in_reply_to_user_id is not None:
if hasattr(tweet, "retweeted_status"):
#Get content
otweet = tweet
tweet = tweet.retweeted_status
if tweet.user.screen_name != screen_name:
#Get full text
text = html.unescape(tweet.full_text)
#Get medias urls
imgs = []
if hasattr(tweet, "extended_entities"):
entities = tweet.extended_entities
entities = tweet.entities
if "media" in entities:
for medialist in entities["media"]:
tweets.append([text, imgs])
for text, imgs in reversed(tweets):
if dry_run:
print (text)
medias = []
for img in imgs :
#Upload media content
mime_type = mimetypes.guess_type(img)[0]
media_file = urllib.request.urlopen(img)
medias.append(mstdn.media_post(media_file=media_file, mime_type=mime_type))
#Post to mastodon
mstdn.status_post(status=text, media_ids=medias)
#Store last_id to start next run from the last Twitter post processed
if new_id != 0 and not dry_run:
config.set("twitter", "last_id", str(new_id))
with open(config_file_path, 'w') as configfile:
if __name__ == "__main__":
parser = argparse.ArgumentParser(prog='twitter2mastodon')
parser.add_argument("-c", "--config", help="config file")
parser.add_argument('-n', '--dry-run', dest='dry_run', action='store_true', default=False, help='Do not post to Mastodon')
args = parser.parse_args()
if not args.config:
post(config_file_path=args.config, dry_run=args.dry_run)