commit 06cdd3b2a1bcd6ecb0a58982b5357955c08cbf87 Author: Linus2punkt0 Date: Wed Aug 9 12:54:34 2023 +0200 rebased diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4569566 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__ +logs/* +images/* +database.json +database.bak \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..226f948 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# bluesky-crossposter + +The Bluesky Crossposter is a python script that when running will automatically post your bluesky-posts to mastodon and twitter, excluding responses and reposts. The script can handle threads and image posts, including alt text on images. + +To get started, get the necessary keys and passwords and enter them in auth.py. Then fill in your paths in path.py. Finally set up a way for the code to be run periodically, for example a cronjob running every five or ten minutes. + +Bluesky Crossposter™©® developed by denvitadrogen diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..91ed868 --- /dev/null +++ b/auth.py @@ -0,0 +1,16 @@ +# All necessary tokens, passwords, etc. +# Your bluesky handle should include your instance, so for example handle.bsky.social if you are on the main one. +bsky_handle = "" +# Generate an app password in the settings on bluesky. DO NOT use your main password. +bsky_password = "" +# The mastodon instance your account is on +MASTODON_INSTANCE = "" +# Generate your token in the development settings on your mastodon account. Token must have the permissions to +# post statuses (write:statuses) +MASTODON_TOKEN = "" +# Get api keys and tokens from the twitter developer portal (developer.twitter.com). You need to create a project +# and make sure the access token and secret has read and write permissions. +TWITTER_APP_KEY = "" +TWITTER_APP_SECRET = "" +TWITTER_ACCESS_TOKEN = "" +TWITTER_ACCESS_TOKEN_SECRET = "" \ No newline at end of file diff --git a/crosspost.py b/crosspost.py new file mode 100644 index 0000000..eddc208 --- /dev/null +++ b/crosspost.py @@ -0,0 +1,315 @@ +from atproto import Client +import tweepy +from mastodon import Mastodon +from datetime import datetime, timedelta +from auth import * +from paths import * +import json, os, urllib.request, random, string, shutil + +date_in_format = '%Y-%m-%dT%H:%M:%S' + +# Setting up connections to bluesky, twitter and mastodon + +bsky = Client() +bsky.login(bsky_handle, bsky_password) + +# After changes in twitters API we need to use tweepy.Client to make posts as it uses version 2.0 of the API. +# However, uploading images is still not included in 2.0, so for that we need to use tweepy.API, which uses +# the previous version. +twitter = tweepy.Client(consumer_key=TWITTER_APP_KEY, + consumer_secret=TWITTER_APP_SECRET, + access_token=TWITTER_ACCESS_TOKEN, + access_token_secret=TWITTER_ACCESS_TOKEN_SECRET) + +tweepy_auth = tweepy.OAuth1UserHandler(TWITTER_APP_KEY, TWITTER_APP_SECRET, TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_TOKEN_SECRET) +twitter_images = tweepy.API(tweepy_auth) + +mastodon = Mastodon( + access_token = MASTODON_TOKEN, + api_base_url = MASTODON_INSTANCE +) + +# Getting posts from bluesky + +def getPosts(): + posts = {} + # Getting feed of user + profile_feed = bsky.bsky.feed.get_author_feed({'actor': bsky_handle}) + for feed_view in profile_feed.feed: + # If post has an embed of type record it is a quote post, and should not be crossposted + if feed_view.post.embed and hasattr(feed_view.post.embed, "record"): + continue + images = "" + cid = feed_view.post.cid + text = feed_view.post.record.text + timestamp = datetime.strptime(feed_view.post.indexedAt.split(".")[0], date_in_format) + timedelta(hours = 2) + # Setting replyToUser to the same as user handle and only changing it if the tweet is an actual reply. + # This way we can just check if the variable is the same as the user handle later and send through + # both tweets that are not replies, and posts that are part of a thread. + replyToUser = bsky_handle + replyTo = "" + if feed_view.post.record.reply: + replyToUser = feed_view.reply.parent.author.handle + replyTo = feed_view.post.record.reply.parent.cid + # Checking if post is by user (i.e. not a repost), withing the last 12 hours and either not a reply or a reply in a thread. + if feed_view.post.author.handle == bsky_handle and timestamp > datetime.now() - timedelta(hours = 12) and replyToUser == bsky_handle: + # Fetching images if there are any in the post + if feed_view.post.embed and hasattr(feed_view.post.embed, "images"): + images = feed_view.post.embed.images + postInfo = { + "text": text, + "replyTo": replyTo, + "images": images + } + # Saving post to posts dictionary + posts[cid] = postInfo; + return posts + +def post(posts): + # Running through the posts dictionary reversed, to get oldest posts first. + for cid in reversed(list(posts.keys())): + # Checking if the post is already in the database, and in that case getting the IDs for the post + # on twitter and mastodon. If one or both of these IDs are empty, post will be sent. + tweetId = "" + tootId = "" + if cid in database: + tweetId = database[cid]["twitterId"] + tootId = database[cid]["mastodonId"] + text = posts[cid]["text"] + replyTo = posts[cid]["replyTo"] + images = posts[cid]["images"] + tweetReply = "" + tootReply = "" + # If it is a reply, we get the IDs of the posts we want to reply to from the database. + # If post is not found in database, we can't continue the thread on mastodon and twitter, + # and so we skip it. + if replyTo in database: + tweetReply = database[replyTo]["twitterId"] + tootReply = database[replyTo]["mastodonId"] + elif replyTo and replyTo not in database: + continue + # If either tweet or toot has not previously been posted, we download images (given the post includes images). + if not tweetId or not tootId: + images = getImages(images) + # Trying to post to twitter and mastodon. If posting fails the post ID for each service is set to an + # empty string, letting the code know it should try again next time the code is run. + if not tweetId: + try: + tweetId = tweet(text, tweetReply, images) + except Exception as error: + print(error) + tweetId = "" + if not tootId: + try: + tootId = toot(text, tootReply, images) + except Exception as error: + print(error) + tootId = "" + # Saving post to database + jsonWrite(cid, tweetId, tootId) + +# Function for posting tweets +def tweet(post, replyTo, images): + mediaIds = [] + # If post includes images, images are uploaded so that they can be included in the tweet + if images: + mediaIds = [] + for image in images: + filename = image["filename"] + alt = image["alt"] + res = twitter_images.media_upload(filename) + id = res.media_id + # If alt text was added to the image on bluesky, it's also added to the image on twitter. + if alt: + writeLog("Uploading image " + filename + " with alt: " + alt + " to twitter") + twitter_images.create_media_metadata(id, alt) + mediaIds.append(id) + # I wanted to make this part a little neater, but didn't get it to work and gave up. So here we are. + # If post is both reply and has images it is posted as both a reply and with images (duh). + # If just either of the two it is posted with just that, and if neither it is just posted as a text post. + if replyTo and mediaIds: + a = twitter.create_tweet(text=post, in_reply_to_tweet_id=replyTo, media_ids=mediaIds) + elif replyTo: + a = twitter.create_tweet(text=post, in_reply_to_tweet_id=replyTo) + elif mediaIds: + a = twitter.create_tweet(text=post, media_ids=mediaIds) + else: + a = twitter.create_tweet(text=post) + writeLog("Posted to twitter") + id = a[0]["id"] + return id + +# More or less the exact same function as for tweeting, but for tooting. +def toot(post, replyTo, images): + mediaIds = [] + # If post includes images, images are uploaded so that they can be included in the toot + if images: + for image in images: + filename = image["filename"] + alt = image["alt"] + # If alt text was added to the image on bluesky, it's also added to the image on mastodon, + # otherwise it will be uploaded without alt text. + if alt: + writeLog("Uploading image " + filename + " with alt: " + alt + " to mastodon") + res = mastodon.media_post(filename, description=alt) + else: + writeLog("Uploading image " + filename) + res = mastodon.media_post(filename) + mediaIds.append(res.id) + # I wanted to make this part a little neater, but didn't get it to work and gave up. So here we are. + # If post is both reply and has images it is posted as both a reply and with images (duh). + # If just either of the two it is posted with just that, and if neither it is just posted as a text post. + if replyTo and mediaIds: + a = mastodon.status_post(post, in_reply_to_id=replyTo, media_ids=mediaIds) + elif replyTo: + a = mastodon.status_post(post, in_reply_to_id=replyTo, visibility="unlisted") + elif mediaIds: + a = mastodon.status_post(post, media_ids=mediaIds, visibility="unlisted") + else: + a = mastodon.status_post(post, visibility="unlisted") + writeLog("Posted to mastodon") + id = a["id"] + return id + +# Function for getting included images. If no images are included, an empty list will be returned, +# and the posting functions will know not to include any images. +def getImages(images): + localImages = [] + for image in images: + # Getting alt text for image. If there is none this will be an empty string. + alt = image.alt + # Giving the image just a random filename + filename = ''.join(random.choice(string.ascii_lowercase) for i in range(10)) + ".jpg" + filename = imagePath + filename + # Downloading fullsize version of image + urllib.request.urlretrieve(image.fullsize, filename) + # Saving image info in a dictionary and adding it to the list. + imageInfo = { + "filename": filename, + "alt": alt + } + localImages.append(imageInfo) + return localImages + +# Function for writing new lines to the database +def jsonWrite(skeet, tweet, toot): + ids = { + "twitterId": tweet, + "mastodonId": toot + } + # When running, the code saves the database to memory, so instead of just saving the post to the database file, + # we also save it to the open database. This also overwrites the version of the post in memory in case + # an ID that was missing because of a previous failure. + database[skeet] = ids + row = { + "skeet": skeet, + "ids": ids + } + jsonString = json.dumps(row) + # If the database file exists we want to append to it, otherwise we create it anew. + if os.path.exists(databasePath): + append_write = 'a' + else: + append_write = 'w' + # Skipping adding posts to db file if they are already in it. + if not isInDB(jsonString): + writeLog("Adding to database: " + jsonString) + file = open(databasePath, append_write) + file.write(jsonString + "\n") + file.close() + +# Function for reading database file and saving values in a dictionary +def jsonRead(): + database = {} + if os.path.exists(databasePath): + with open(databasePath, 'r') as file: + for line in file: + jsonLine = json.loads(line) + database[jsonLine["skeet"]] = jsonLine["ids"] + return database; + +# Function for checking if a line is already in the database-file +def isInDB(line): + with open(databasePath, 'r') as file: + content = file.read() + if line in content: + return True + else: + return False + +# Function for writing to the log file +def writeLog(message): + now = datetime.now().strftime("%d/%m/%Y %H:%M:%S") + date = datetime.now().strftime("%y%m%d") + message = str(now) + ": " + message + "\n" + log = logPath + date + ".log" + if os.path.exists(log): + append_write = 'a' + else: + append_write = 'w' + dst = open(log, append_write) + dst.write(message) + print(message) + dst.close() + +# Cleaning up downloaded images +def cleanup(): + for filename in os.listdir(imagePath): + file_path = os.path.join(imagePath, filename) + try: + if os.path.isfile(file_path) or os.path.islink(file_path): + os.unlink(file_path) + elif os.path.isdir(file_path): + shutil.rmtree(file_path) + except Exception as e: + writeLog('Failed to delete %s. Reason: %s' % (file_path, e)) + +# Since we are working with a version of the database in memory, at the end of the run +# we completely overwrite the database on file with the one in memory. +# This does kind of make it uneccessary to write each new post to the file while running, +# but in case the program fails halfway through it gives us kind of a backup. +def saveDB(): + writeLog("Saving new database") + append_write = "w" + for skeet in database: + row = { + "skeet": skeet, + "ids": database[skeet] + } + jsonString = json.dumps(row) + file = open(databasePath, append_write) + file.write(jsonString + "\n") + file.close() + append_write = "a" + +# Function for counting lines in a file +def countLines(file): + with open(file, 'r') as file: + for count, line in enumerate(file): + pass + return count + +# Every twelve hours a backup of the database is saved, in case something happens to the live database. +# If the live database contains fewer lines than the backup it means something has probably gone wrong, +# and before the live database is saved as a backup, the current backup is saved as a new file, so that +# it can be recovered later. +def dbBackup(): + if not os.path.isfile(backupPath) or os.stat(backupPath).st_mtime > 86400: + if os.path.isfile(backupPath) and countLines(backupPath) < countLines(databasePath): + os.remove(backupPath) + elif os.path.isfile(backupPath) and countLines(backupPath) > countLines(databasePath): + date = datetime.now().strftime("%y%m%d") + os.rename(backupPath, backupPath + "_" + date) + writeLog("Current backup file contains more entries than current live database, backup saved") + shutil.copyfile(databasePath, backupPath) + writeLog("Backup of database taken") + +# Here the whole thing is run +database = jsonRead() +posts = getPosts() +post(posts) +saveDB() +cleanup() +dbBackup() +if not posts: + writeLog("No new posts found.") diff --git a/paths.py b/paths.py new file mode 100644 index 0000000..bc6d626 --- /dev/null +++ b/paths.py @@ -0,0 +1,14 @@ +# This file contains all necessary file and folder paths. Make sure to end folder paths with "/". + +# basePath is the path from root to he lowest common denominator for all of the other paths. +# Using an absolute path is especially important if running via cron. +basePath = "/" +# Path to the database file. If you want it somewhere other than directly in the base path you can +# either write the entire path manually, or just add the rest of the path on top of the basePath. +databasePath = basePath + "database.json" +# Path to backup of database. +backupPath = basePath + "database.bak" +# Path for storing logs +logPath = basePath + "logs/" +# Path to folder for temporary storage of images +imagePath = basePath + "images/" \ No newline at end of file