Crosspost von Mastodon zu Bluesky

Mastodon Posts automatisch auch bei Bluesky veröffentlichen - so geht's

Neben Mastodon, tummeln sich inzwischen zwei andere neue Netzwerke, Threads.net und Bluesky. Während Threads gerade erst zögerlich an einer Integration von ActivityPub arbeitet und bisher keinerlei API bereitstellt, stehen bei BlueSky bereits ein paar Schnittstellen zur Verfügung.

Dank API ist es dort möglich, Beiträge zu posten, ohne die App oder Webseite benutzen zu müssen. Das können wir nutzen, um ein Script zu schreiben, welches uns beim Crossposten hilft.

Crossposting

Das Ziel: Einen Post bei Mastodon schreiben und diesen dann automatisiert, kurz darauf automatisch auch bei Bluesky zu posten. Realisieren werden wir das mit einem kleinen NodeJS Script, das dann dauerhaft im Hintergrund laufen kann und uns die Arbeit abnimmt.

Vorbereitungen

Damit wir bim Bluesky posten können, benötigen wir einen API-Zugang. Es empfiehlt sich, nicht die normalen Bluesky Login Daten zu verwenden, sondern ein AppPasswort anzulegen. Dazu gehen wir in die Settings und können dort ein neues AppPasswort anlegen. Zunächst müssen wir einen Namen festlegen, der sollte möglichst vielsagend sein, beispielsweise MastodonToBluesky.

Es wird ein neues Passwort generiert, welches wir sicher ablegen sollten, am besten im Passwortmanager unserer Wahl. Wir können das Passwort danach nämlich bei Bluesky nicht mehr einsehen.

Um Beiträge bei Bluesky zu veröffentlichen, werden wir das offizielle atproto Paket verwenden. Das macht uns das Leben recht einfach, kümmert sich um die Anmeldung mit unseren Login-Daten und unterstützt uns später beim Erstellen des Posts.

Auf der anderen Seite müssen wir Mastodon anzapfen, um an unsere neusten Posts zu kommen. Dazu könnten wir die Mastodon-API nutzen. Mastodon stellt allerdings auch bereitwillig eine Outbox für jeden Account bereit – ganz ohne API. Diese Outbox antwortet uns mit einem JSON-Response, das alle Daten enthält, die wir benötigen. Wir brauchen uns also nicht um einen API-Zugang bemühen, sondern können einfach die folgende URL aufrufen:

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

Natürlich müssen INSTANCE und USERNAME gegen die korrekten Daten ausgetauscht werden, in meinem Fall wäre das mastodon.online und mauricerenck. Für einen schnellen Check rufen wir die URL einfach im Browser auf und sollten eine JSON-Text-Antwort sehen. Der page Parameter ermöglicht es uns in den Datensätzen vor und zurückspringen.

Der Ablauf

Jetzt, wo wir auf beiden Seiten Zugriff auf die Daten und Schnittstellen haben, können wir uns kurz anschauen, wie das fertige Script dann arbeiten wird:

  • Alle paar Minuten rufen wir die Mastodon-Outbox auf und fragen die letzten Posts ab
  • Wir gehen alle Posts durch und filtern Replies raus, die bringen uns bei Bluesky nichts
  • Jeden übrig gebliebenen Post schicken wir über die API als neuen Post an Bluesky.

Den Status speichern

Wir haben hier allerdings noch ein Problem: Wenn wir alle paar Minuten durch die Liste der Posts bei Mastodon gehen, werden wir dort nicht immer ausschließlich neue Posts finden. Wenn wir jedes Mal die Posts durchgehen und 1:1 bei Bluesky posten, wird das zu massenhaften Duplikaten führen. Wir müssen uns also irgendwie merken, welche Posts wir bereits an Bluesky weitergereicht haben. Dazu speichern wir uns die entsprechende ID in eine Datei. Falls unser Script mal abstürzt oder anderweitig beendet oder neu gestartet wird, können wir beim erneuten Starten, einfach diese Datei wieder auslesen und wissen, an welcher Position wir beim letzten Durchlauf aufgehört haben.

Das Script

Wir beginnen damit, zwei Dateien anzulegen:

  1. main.js
  2. lastProcessedPostId.txt

Die Datei main.js enthält unseren Quellcode, die zweite Datei dient zum Speichern der letzten Post-ID, die verarbeitet wurde. Hier tragen wir initial eine 0 ein. Das ist wichtig!

Beim Starten des Scripts werden wir als Erstes diese letzte ID auslesen. Dazu legen wir zunächst den Pfad und Dateinamen für die Datei fest, aus der wir diese Information auslesen können. Dann lesen wir die Datei aus und merken uns die ID in einer 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;
  }
}

Kann die Datei lastProcessedPostId.txt aus irgendeinem Grund nicht gelesen werden, loggen wir eine Fehlermeldung und das Script wird beendet. Geht alles gut, haben wir die letzte ID in der Variable lastProcessedPostIdstehen und können damit arbeiten.

Posts bei Mastodon abholen

Jetzt können wir neue Beiträge bei Mastodon abholen. Dazu benötigen wir zwei Informationen:

  • Die Mastodon Instanz
  • Den Mastodon Username

Da diese Informationen nicht sicherheitsrelevant sind, könnten wir sie direkt in den Code schreiben, aber davon halte ich nicht sonderlich viel und rate dazu, sie in einer Konfigurationsdatei abzulegen. Später werden wir dort auch noch unsere Bluesky-Zugangsdaten hinterlegen.

Wir nutzen dazu eine Environment-Datei .env. Das hat den Vorteil, dass wir dort sensible Informationen ablegen können, ohne dass diese beispielsweise aus Versehen im Git-Repository landen, dazu nutzen wir das dotenv Paket.

Wir legen nun also eine Datei namens .env an und speichern darin in meinem Fall:

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

In unserer JavaScript-Datei können wir nach dem import vom dotenv Paket nun darauf zugreifen:

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

Jetzt sind wir bereit und können uns die Outbox mit einem einfachen GET-Call holen. Die Antwort sollte eine Liste der letzten Posts enthalten. Wie wir bereits wissen, müssen wir diese Liste aber noch etwas filtern. Wir wollen keine Replies posten, weil es die User bei Bluesky nicht geben wird und wir wollen keine Duplikate posten, müssen also auf unsere gespeicherte ID zurückgreifen:

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();
  }
}

Wir starten mit unserem GET-Request, um das JSON von Mastodon zu holen. Dann filtern wir die Posts so, dass wir nur von uns erstellte Posts bekommen und keine Replies. Wir speichern das Ergebnis als Array ab, welches wir noch einmal umdrehen, damit der älteste Post ganz oben in der Liste steht, der neueste ganz unten. So können wir dann einfach die Liste von oben nach unten durchgehen und bleiben in der richtigen zeitlichen Abfolge.

Jeder Post hat den Zeitpunkt der Veröffentlichung als Datum hinterlegt. Diese Information nehmen wir uns und machen ein JavaScript-Datum daraus. Das benutzen wir als ID. Wir könnten auch die von Mastodon erzeugte ID verwenden, da wir aber chronologisch bleiben wollen, können wir auf diese Weise davon ausgehen, dass wir nicht weiterschauen müssen, sobald ein Datum älter oder gleich alt wie der zuletzt verarbeitete Post ist.

Ist der Zeitstempel neuer als das zuletzt gemerkte Datum, schicken wir den Post weiter an Bluesky. Vorher entfernen wir noch mögliche HTML-Tags, wir wollen reinen Text haben.

Ganz zum Schluss speichern wir dann noch die neuste ID ab, damit wir diese beim nächsten Start des Scripts wieder im Zugriff haben.

Posten bei Bluesky

Um bei Bluesky posten zu können, verwenden wir das offizielle @atproto/api Paket. Dazu benötigen wir ein paar Informationen, um uns bei der API anzumelden:

  • Die Instanz
  • Den Handle (Username)
  • Das Passwort

Diese Daten legen wir ebenfalls in unserer .env Datei ab:

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

Derzeit gibt es nur eine Bluesky-Instanz, der Endpunkt sollte also überall gleich aussehen. Beim Passwort fügen wir das vorhin erstellte AppPassword ein.

Nun ist es an der Zeit, die Funktion zum Posten zu schreiben. Dazu erstellen wir uns noch den Bluesky Agent:

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

Jetzt können wir posten:

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,
  });
}

Über den Agent melden wir uns zunächst an. Dann geben wir den Text unseres Mastodon-Posts an die RichText-Klasse weiter. Die benutzen wir, damit wir nicht nur reinen Text, sondern zum Beispiel auch Links posten können, die dann auch als solche erkannt werden.

Jetzt müssen wir den Ablauf eigentlich nur noch regelmäßig aufrufen:

setInterval(fetchNewPosts, 2 * 60 * 1000);

Damit rufen wir alle zwei Minuten neue Posts ab und leiten sie ggf. an Bluesky weiter.

Ich habe noch ein paar andere Funktionen in den obigen Beispielen verwendet, die ich an dieser Stelle nicht im Detail ausführen möchte, weil es sich nur um kleine Helfer handelt. Du kannst dir das gesamte Script, inklusive dieser Funktionen aber hier ansehen:

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

Das Script können wir jetzt einfach dauerhaft laufen lassen. Dazu bietet sich etwa ein Raspberry Pi an. Alternativ setze ich für solche Dinge gerne auf Koyeb.

Das Script holt sich alle zwei Minuten die neuen Posts, verarbeitet sie und reicht sie ggf. an Bluesky weiter. Da wir beim ersten Aufruf noch keinen einzigen Post rübergeschickt haben und mit einem Schlag zahlreiche neue Posts erzeugen würden, enthält das Script einen Mechanismus, der nur die Posts weiterleitet, die nach dem ersten Aufruf des Scripts veröffentlicht wurden.

Hiermit haben wir eine gut funktionierende, aber doch recht rudimentäre Lösung für das Crossposten an der Hand. Wir können das Script einfach so laufen lassen, oder noch weiter ausbauen und verbessern. Lass mich gerne wissen, was du noch für Verbesserungen vorgenommen hast – gerne hier als Kommentar.

Viel Erfolg bei der Umsetzung.

Wie geht's von hier aus weiter?

Wenn du diesen Beitrag (nicht) gut findest, kannst du ihn kommentieren, woanders darüber schreiben oder ihn teilen. Wenn du mehr Beiträge dieser Art lesen willst, kannst du mir via RSS oder ActivityPub folgen, oder du kannst kannst dir ähnliche Beiträge ansehen.