Crosspost from Mastodon to Bluesky

How to automatically send your Mastodon posts to Bluesky

Since the publication of this post, the code in the repository mentioned below has been further optimized and even a Docker image has been provided. If you do not want to rebuild the tool, you can use it with your own configuration in this way.

In addition to Mastodon, two other new networks have emerged: Threads.net and Bluesky. While Threads is currently working slowly on integrating ActivityPub and does not yet provide any API, Bluesky already has a few interfaces available.

Thanks to the API, it is possible to post without having to use the app or website. We can use this to write a script that helps us with crossposting.

Crossposting

The goal: Write a post on Mastodon and then automatically, shortly afterward, post it on Bluesky as well. We will achieve this with a small Node.js script that can run continuously in the background, taking care of the process for us.

Preparations

To post on Bluesky, we need an API access. It is advisable not to use regular Bluesky login credentials, but to create an AppPassword. To achieve this, go to Settings and create a new AppPassword. First, we need to set a name, which should be meaningful, for example, MastodonToBluesky.

A new password will be generated, which should be securely stored, preferably in our preferred password manager. Afterward, we won't be able to view the password on Bluesky.

To publish posts on Bluesky, we will use the official atproto package. It makes our lives quite easy, takes care of logging in with our login credentials, and helps us later in creating the post.

On the other side, we need to tap into Mastodon to get our latest posts. We could use the Mastodon API, but Mastodon willingly provides an Outbox for each account — completely without API. This Outbox responds to us with a JSON response containing all the data we need. Therefore, we don't need to bother with an API access; we can simply call the following URL:

https://INSTANCE/users/USERNAME/outbox?page=true

Of course, INSTANCE and USERNAME need to be replaced with the correct data; in my case, it would be mastodon.online and mauricerenck. For a quick check, simply open the URL in your browser, and you should see a JSON text response. The page parameter allows us to navigate through the records forward and backward.

The Workflow

Now that we have access to data and interfaces on both sides, let's take a brief look at how the finished script will work:

  • Every few minutes, we retrieve the Mastodon Outbox and fetch the latest posts.
  • We go through all posts and filter out replies, as they are not relevant for Bluesky.
  • We send each remaining post as a new post to Bluesky using the API.

Saving the Status

However, we have a problem: if we go through the list of Mastodon posts every few minutes, we won't always find exclusively new posts there. If we go through the posts each time and post them 1:1 on Bluesky, it will lead to a mass of duplicates. Therefore, we need to somehow remember which posts we have already forwarded to Bluesky. To accomplish this, we save the corresponding ID in a file. If our script crashes or is otherwise terminated or restarted, we can simply read this file again on the next start and know at which position we left off in the last run.

The Script

Let's start by creating two files:

  1. main.js
  2. lastProcessedPostId.txt

The file main.js contains our source code, and the second file is used to store the last processed Mastodon post ID. We initially enter a 0 in it. This is important!

When starting the script, we first read this last ID. To do this, we first set the path and filename for the file from which we can read this information. Then we read the file and store the ID in a variable:

// File to store the last processed Mastodon post ID
const lastProcessedPostIdFile = path.join(__dirname, 'lastProcessedPostId.txt');

// Variable to store the last processed Mastodon post ID
let lastProcessedPostId = loadLastProcessedPostId();

// Function to load the last processed post ID from the file
function loadLastProcessedPostId() {
  try {
    return fs.readFileSync(lastProcessedPostIdFile, 'utf8').trim();
  } catch (error) {
    console.error('Error loading last processed post ID:', error);
    return null;
  }
}

If the file lastProcessedPostId.txt cannot be read for any reason, we log an error message, and the script terminates. If everything goes well, we have the last ID stored in the variable lastProcessedPostId and can work with it.

Retrieving Posts from Mastodon

Now we can fetch new posts from Mastodon. For this, we need two pieces of information:

  • The Mastodon instance
  • The Mastodon username

Since this information is not security-relevant, we could write it directly into the code, but I don't recommend that and suggest storing it in a configuration file. Later, we will also store our Bluesky credentials there.

We use an environment file .env for this purpose. The advantage is that we can store sensitive information there without it accidentally ending up in the Git repository. We use the dotenv package for this.

So, we create a file named .env and save in it, in my case:

MASTODON_INSTANCE="https://mastodon.online"
MASTODON_USER="mauricerenck"

In our JavaScript file, after importing the dotenv package, we can access this information:

require('dotenv').config()
const mastodonInstance = process.env.MASTODON_INSTANCE;
const mastodonUser = process.env.MASTODON_USER;

Now we are ready and can retrieve the Outbox with a simple GET call. The response should contain a list of the latest posts. As we already know, we still need to filter this list a bit. We would rather not post replies because there will be no corresponding users on Bluesky, and we don't want to post duplicates, so we need to refer to our stored ID:

async function fetchNewPosts() {
  const response = await axios.get(`https://${mastodonInstance}/users/${mastodonUser}/outbox?page=true`);

  const reversed = response.data.orderedItems.filter(item => item.object.type === 'Note')
    .filter(item => item.object.inReplyTo === null)
    .reverse();

  let newTimestampId = 0;

  reversed.forEach(item => {
    const currentTimestampId = Date.parse(item.published);

    if(currentTimestampId > newTimestampId) {
      newTimestampId = currentTimestampId;
    }

   if(currentTimestampId > lastProcessedPostId && lastProcessedPostId != 0) {
      const text = removeHtmlTags(item.object.content);
      postToBluesky(text);
    }
  })

  if(newTimestampId > 0) {
    lastProcessedPostId = newTimestampId;
    saveLastProcessedPostId();
  }
}

We start with our GET request to retrieve the Mastodon JSON. Then we filter the posts so that we only get posts created by us and no replies. We save the result as an array, which we reverse so that the oldest post is at the top of the list, and the newest one is at the bottom. This way, we can simply go through the list from top to bottom and stay in the correct chronological order.

Each post has the publication time stored as a date. We use this information and create a JavaScript date from it, which we use as an ID. We could also use the ID generated by Mastodon, but since we want to stay chronological, we can assume that we don't need to look further once a date is older or equal to the last processed post.

If the timestamp is newer than the last recorded date, we send the post to Bluesky. Before that, we remove any possible HTML tags; we want pure text.

Finally, we save the newest ID so that we have it accessible the next time the script is started.

Posting to Bluesky

To post on Bluesky, we use the official @atproto/api package. For this, we need a few pieces of information to log in to the API:

  • The endpoint
  • The Handle (Username)
  • The Password

We also store this data in our .env file:

BLUESKY_ENDPOINT="https://bsky.social"
BLUESKY_HANDLE="USERNAME.bsky.social"
BLUESKY_PASSWORD="PASSWORD"

Currently, there is only one Bluesky instance, so the endpoint should look the same everywhere. For the password, we insert the previously created AppPassword.

Now it's time to write the function for posting. For this, we also create the Bluesky agent:

const agent = new BskyAgent({ service: process.env.BLUESKY_ENDPOINT });

Now we can post:

async function postToBluesky(text) {
  await agent.login({
    identifier: process.env.BLUESKY_HANDLE,
    password: process.env.BLUESKY_PASSWORD,
  });

  const richText = new RichText({ text });
  await richText.detectFacets(agent);
  await agent.post({
    text: richText.text,
    facets: richText.facets,
  });
}

We first log in via the agent. Then we pass the text of our Mastodon post to the RichText class. We use it so that we can post not only plain text but, for example, also links, which will be recognized as such.

Now we just need to call this workflow regularly:

setInterval(fetchNewPosts, 2 * 60 * 1000);

This way, we fetch new posts every two minutes and potentially forward them to Bluesky.

I have used a few other functions in the above examples that I don't want to go into detail about here because they are just small helpers. You can view the entire script, including these functions, here:

GitHub - mauricerenck/mastodon-to-bluesky: A Node.js script for crossposting from mastodon to bluesky

https://github.com/mauricerenck/mastodon-to-bluesky

Now we can simply let the script run continuously. For this, a Raspberry Pi is a suitable option. Alternatively, I often use Koyeb for such things.

The script now fetches new posts every two minutes, processes them, and forwards them to Bluesky if necessary. Since we haven't sent a single post over on the first call and would generate numerous new posts with a single stroke, the script includes a mechanism that only forwards posts that were published after the first call of the script.

With this, we have a well-functioning but quite rudimentary solution for crossposting. You can either let the script run as it is, or further expand and improve it. Please let me know what improvements you have made — feel free to share them here as a comment.

Good luck with the implementation!

What you could do now

If you (don't) like this post, you can comment, write about it elsewhere, or share it. If you want to read more posts like this, you can follow me via RSS or ActivityPub, or you can view similar posts.