Writing first Mastodon bot

Over the years I’ve written quite a few Twitter bots. But since Elon Musk took over – the bird site has become unbearable, so I, like many others, migrated to Mastodon. What Mastodon is, and how it operates is a whole another story, but for our intents and purposes it is similar to Twitter: there is a home timeline where posts from people you follow appear, and you can post to the timeline as well.

Back on Twitter I used to have a bot that would tweet one-liners from Pink Floyd lyrics every hour. Follow me along as I recreate it on Mastodon.

First and foremost you have to make sure the Mastodon instance you’re on allows bots. Some do, some don’t – read the server rules to find out. I am using botsin.space instance that is specifically meant to host bots.

Once you created an account on the instance (you may have to wait for manual admin approval) go to Preferences -> Development and create an “application” there

Screenshot of Mastodon preferences showing Development opinion.

The application lets you set permission scopes for your bot. I left default permissions as is, but for our purposes we just need write:statuses permissions because our bot is only going to post to timeline at this time. The application also provides access token for your bot to authenticate with.

With this out of the way you can start coding your bot. I am using TypeScript, and recommend Megalodon package that abstracts Mastodon API and makes interaction with Mastodon much simpler. With Megalodon only about 5% of code actual Mastodon posting, the rest is text processing. I am using a CSV file for lyrics so a CSV parser is needed. Let’s start with some imports:

import generator from 'megalodon';
import fs from 'fs';
import { parse } from 'csv-parse/sync';

Next – create a Mastodon client to do posting with:

const BASE_URL: string = 'https://botsin.space';
const access_token: string = process.env.API_TOKEN!;
const client = generator('mastodon', BASE_URL, access_token);

You specify URL of your instance and authentication token you got when creating Mastodon application. Next, let’s create a function that would load the CSV file, parse it into separate songs, and within each song splits lyrics in separate lines:

class MyArray extends Array<string> {
  lyrics?: string[];
}

function loadSongs() {
  const csv: MyArray[] = parse(
    fs.readFileSync('./pink_floyd_lyrics.csv', 'utf-8')
  );

  // removing songs with empty lyrics (instrumentals)
  const songs = csv.filter(song => song[3].trim() !== '');

  songs.sort(() => 0.5 - Math.random()); // shuffling songs

  // splitting lyrics into lines
  for (let song of songs) {
    song.lyrics = song[3].replace('\n\n', '\n')
      .split('\n')
      // removing short lines
      .filter(line => line.trim().length > 20);
    // shuffling lines
    song.lyrics.sort((a, b) => 0.5 - Math.random());
  }

  return songs;
}

Here I wanted each song (which is technically an array of strings [album, song_title, year, lyrics]) to have an extra property lyrics that would hold lyrics split into individual lines, hence a bit of TypeScript voodoo magic in Lines 01-03. The function loads text file with lyrics, parses it as CSV file, and removes songs with no lyrics (Lines 06-11). It then shuffles the array of songs in random order (note the unorthodox use of the Array.sort() method on Line 13). After that we’re looping thru the array of songs splitting lyrics for each song into individual lines, filtering out short lines, and applying the same type of shuffle to the array of lines like we did to the list of songs previously (Lines 15-23).

The next step is the function that will be called every hour to post a quote

function doQuote() {
 
    const songIndex = Math.floor(Math.random() * songs.length)
    const song = songs[songIndex];
 
    if (song.lyrics!.length !== 0) {
 
        const lineIndex = Math.floor(Math.random() * 
                               song.lyrics!.length)
        const line = song.lyrics![lineIndex];
 
        const toot = line + '\n\n' +
            '#' + song[1].replace(/[` .,-?!'’()]/g, '') + ' ' +
            '#' + song[0].replace(/[` .,-?!'’()]/g, '') + ' ' +
            '#PinkFloyd'
 
        console.log(`\n${(new Date()).toLocaleString()}: ${toot}`);
 
        client.postStatus(toot)
              .catch(err => console.error(err))
 
        song.lyrics!.splice(lineIndex, 1)
        console.log(`- Song has ${song.lyrics!.length} lines left`)
    }
 
    if (song.lyrics!.length === 0) {
        songs.splice(songIndex, 1);
        console.log(`- ${songs.length} songs left`)
 
        if (songs.length === 0) {
            console.log(`- reloading songs`)
            songs = loadSongs()
        }
    }
 
}

Out of of this whole shenanigans only Lines 19-20 do actual Mastodon posting. The rest have to do with selecting random song, selecting random line from the song’s lyrics, and removing used lines and songs to avoid repetition. The only thing left is to call the function to load songs, post initial quote and kick of interval to post quote every hour

console.log(`${(new Date())
       .toLocaleString()}: ****** Floyd Quoter Started *****\n`)
 
let songs = loadSongs()
 
doQuote();
 
setInterval(doQuote, 60 * 60 * 1000); // 1 hour

Leave a Reply

Your email address will not be published. Required fields are marked *