A Quick and Dirty Tutorial for Writing Mastodon Bots in Ruby

Note: see the update below.

My most recent project has been this Mastodon bot. Every n hours (where n == 8, for now) it toots an interesting section of the Mandelbrot Set.

Unfortunately, when getting to the actual bot part of the project, I found very little in the way of tutorials or examples in Ruby, so I'm writing down what I did while it's still fresh in my mind.

1. Create an Account

First, we need an account on a Mastodon instance. I'm using http://botsin.space, an instance for bot writers. This is pretty straightfoward: just fill in your details in the signup form and away you go.

2. Get Credentials

Getting credentials is an overly complicated process that (fortunately) there are tools to help with. Basically you:

  1. Register your application on the Mastodon instance. This gives you two big numbers (the client ID and client secret).

  2. Using these numbers plus the username and password of your bot's account, log in. This gives you your bot's access token. Hold on to this.

  3. From now on, the bot will use the access token to authenticate itself. You can invalidate the access token at any time from the bot account's settings (Settings -> Authorized Apps).

Unfortunately, the Ruby Mastodon client API library doesn't have an easy way to do this for you. After wrestling with it for a while, I ended up just using Darius Kazemi's registration app at https://tinysubversions.com/notes/mastodon-bot/. It's a nice little webapp that does everything reasonably securely. Just log into your instance and, while logged in, go to the above URL and follow the directions. (You do need to have curl installed and available, though.)

Failing that and if you have a Python installation handy, you can use the Python Mastodon module. Allison Parish's excellent guide for doing that is here.

3. Install the Mastodon API Gem

Assuming you have Ruby installed, it's a simple matter of typing

gem install mastodon-api

Unfortunately, as of this writing, there's a bug in the current release which has been fixed in development but which breaks the bot.

I worked around this by creating a Gemfile and listing a recent git commit as the version. (Did you know that bundle can install gems from GitHub? It can!)

It looks like this:

source 'https://rubygems.org'

gem "mastodon-api", require: "mastodon", github: "tootsuite/mastodon-api",
    branch: "master", ref: "a3ff60a"

Then, you cd to the project directory and run bundle:

bundle

(Or, if you want to install the gems locally and aren't using rvm or rbenv, you can install the gems locally:

bundle install --path ~/gems/

But you knew that, right?)

4. Basic Tooting

We are now ready to write a simple bot. This is done in two steps.

First, we create the client object:

client = Mastodon::REST::Client.new(
  base_url: 'https://botsin.space',
  bearer_token: 'bdc1fe7113d7cb9ea0f5d25')

The bearer_token argument is the token you got in step 2. (The real one will be longer than the one in the example.)

Then, we post the toot:

client.create_status("test status!!!!!")

And that should do it.

Here's the complete script:

require 'rubygems'
require 'bundler/setup'

require 'mastodon'

client = Mastodon::REST::Client.new(
  base_url: 'https://botsin.space',
  bearer_token: 'bdc1fe7113d7cb9ea0f5d25')

client.create_status("test status!!!!!")

One thing: because it's using bundler, the script must either be run from the directory containing the Gemfile or you will need to set the BUNDLE_GEMFILE environment variable to point to it.

I ended up doing the latter by writing a shell script to set things up and then launch the bot:

#!/bin/bash

botdir="$(cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd)"

export BUNDLE_GEMFILE="$botdir/Gemfile"
ruby "$botdir/basic_toot.rb" "$@"

The tricky expression in the second non-empty line extracts the directory containing the shell script itself; it figures out the location of the Gemfile from that.

5. Tooting With Media

Going from here to attaching a picture is pretty simple.

You set up the client as before and then upload the picture with upload_media():

form = HTTP::FormData::File.new("#{FILE}.png")
media = client.upload_media(form)

It's possible that you don't need to wrap your filename in a HTTP::FormData::File object (the API docs seem to imply that) but I wasn't able to get that to work and anyway, this doesn't hurt.

The media object you get back from the call to upload_media() contains various bits of information about it including an ID. This gets attached to the toot:

status = File.open("#{FILE}.txt") {|fh| fh.read}    # Get the toot text
sr = client.create_status(status, nil, [media.id])

It's in an array because (of course) you can attach multiple images to a toot.

Here's the entire script:

require 'rubygems'
require 'bundler/setup'

require 'mastodon'

FILE = "media_dir/toot"

client = Mastodon::REST::Client.new(
  base_url: 'https://botsin.space',
  bearer_token: 'bdc1fe7113d7cb9ea0f5d25')

form = HTTP::FormData::File.new("#{FILE}.png")
media = client.upload_media(form)

status = File.open("#{FILE}.txt") {|fh| fh.read}
sr = client.create_status(status, nil, [media.id])

This is mostly I use, plus the whole image plotting stuff, some logging stuff and a bunch of safety and error detection stuff.

6. Auth Token Management

You will note that I store the auth token as a string literal. I do this to simplify the example code but in general, it's a Bad Idea; mixing your credentials with your source code is just asking for your login details to get published on the open Internet. Keep them far away from each other.

For my bot, I use a YAML file that sits in the bot's work directory (which is not part of the source tree). Other options might be to pass it as a command-line argument or store it in an environment variable.

Other Resources

Update

As I discovered the hard way when the microSD card holding the bot's host's filesystem crapped out, the 2.0.0 version of the API changes a couple of things:

  1. client.create_status now takes most of its arguments as keyword arguments, requiring only the status text. The media ID list is passed as media_ids.

  2. client.upload_media now(?) optionally takes an ordinary file handle.

I ended up doing the equivalent of

media = open("#{FILE}.png", "rb") { |fh| client.upload_media(fh) }
status = File.open("#{FILE}.txt") {|fh| fh.read}
sr = client.create_status(status, media_ids: [media.id])

which worked again.


#   Posted 2017-12-15 03:46:21 UTC; last changed 2020-03-04 03:38:04 UTC