Building a Mastodon bot with GO

In this post we're going to see how we can build a Mastodon bot with GO that posts pre-planned post once per day. It will support attachments and respective alt texts to cover visually impaired people as well.

The library we're going to use to talk to the mastodon API is go-mastodon, which supports all the features we need for uploading assets and attaching them to our post.

Preparing a Post/Toot

To keep our future posts nice and organised, we'll drop them into directories that have a $year/$date.toml structure. We'll use [toml][toml] for our content since it doesn't rely on indentation like yaml and lets us create key/value pairs without much trouble.

The first post ever posted by the Game Trivia Bot looks like this:

text = "The official Twitter account for Fall Guys released a \"human for scale\" illustration featuring their playable characters. Fall Guys are all 183cm (6ft) by the way."
tags = ['FallGuys']
assets = ['fallguys-scale.jpeg', 'fallguys-eyes.png']
assetsAlts = ['A Fall Guy next to human', 'Fall Guy eyes and skull']
credits = ['https://www.artstation.com/tudormorris']

We have the body text, the tags which will appear like #FallGuys, the assets (image files) and the alt text for those image files: assetsAlts. Lastly we also have credits for the image we're posting because we obviously want our followers to explore the cool creators behind visuals, talks or screenshots.

For our example we will simplify the format a little bit and only support one media attachment per post, like some of the famous bird site accounts that post a fox or an opossum every hour.

Now we also need a function to load the post and to parse it into a data type that we can work with in our program.

Let's create a file called posts.go to parse an example toml file:

package main

import (
  "fmt"
  "os"
  "time"

  "github.com/BurntSushi/toml"
)

type Post struct {
  Text       string
  Tags       []string
  Asset      string
  AssetAlt   string
  Credits    string
}

func LoadPost() (Post, error) {
  var post Post
  postFileName := "posts/example.toml"

  _, fileExistErr := os.Stat("./" + postFileName)

  if fileExistErr == nil {
    fmt.Printf("File exists, processing\n")
  } else {
    fmt.Printf("File does not exist\n")
    return post, fileExistErr
  }

  _, tomlError := toml.DecodeFile(postFileName, &post)
  if tomlError != nil {
    fmt.Printf("TOML file reading/decoding error: %v\n", tomlError)
  }

  return post, nil
}

To load a post for a specific day, we would have to change the postFileName to something like:

  currentTime := time.Now()
  postFileName := fmt.Sprintf("date-posts/%s/%s.toml", currentTime.Format("2006"), currentTime.Format("2006-01-02"))

which would allow you to use a directory structure like this:

├── date-posts
│   ├── 2022
│   │   ├── 2022-12-01.toml
│   ├── 2023
│   │   ├── 2023-09-23.toml
│   └── drafts

We'll get back to actually using the parsed post later.

Getting the Mastodon API key

Mastodon bots are their own accounts, so you should check with your instance policies if bot accounts are allowed and then register an account with the desired username. Then, in the web interface you need to create an app with the right scopes. We'll need "write" at least, but check whatever you need for your bot.

mastodon application scopes

find your client key and client secret:

client key and secret

We'll also need to tell the bot which server it lives on like streamers.social or mastodon.social.

Loading API Configuration

We need to pass the respective credentials to our bot and when we deploy it we probably also want to read it from the environment and not from a file committed to git, so let's create our main.go file and a config.go file to load our configuration either from a .env file in development or from the ENV variables in production.

main.go:

package main

import (
  "fmt"
  "log"
)

var config map[string]string

func main() {

  envs, error := GetConfig()

  if error != nil {
    log.Fatalf("Error loading .env or ENV: %v", error)
  }

  fmt.Printf("%v", envs)
}

config.go

package main

import (
  "fmt"
  "os"

  "github.com/joho/godotenv"
)

func readConfigFromENV() map[string]string {
  var envs = make(map[string]string)

  envs["MASTODON_SERVER"] = os.Getenv("MASTODON_SERVER")
  envs["APP_CLIENT_ID"] = os.Getenv("APP_CLIENT_ID")
  envs["APP_CLIENT_SECRET"] = os.Getenv("APP_CLIENT_SECRET")
  envs["APP_USER"] = os.Getenv("APP_USER")
  envs["APP_PASSWORD"] = os.Getenv("APP_PASSWORD")

  return envs
}

func GetConfig() (map[string]string, error) {

  if os.Getenv("APP_ENV") != "production" {
    envs, error := godotenv.Read(".env")
    if error != nil {
      fmt.Println("Error loading .env file")
    }
    return envs, error
  } else {
    envs := readConfigFromENV()
    return envs, nil
  }
}

and lastly this is the content of our .env file which sould also be listed in your .gitignore file:

MASTODON_SERVER="https://streamers.social"
APP_CLIENT_ID="2atM1d_KxIswqRn7uvusTe"
APP_CLIENT_SECRET="siuI0GDyOv9xD_htlQ5Tu"
APP_USER="gametrivia@jonathanmh.com"
APP_PASSWORD="super secret password"

You will need to replace the values with the ones you got from the web interface and a strong randomly generated password for your bot account.

Now if we run

go mod init
go mod tidy
go run .

We should see the following output that confirms that our config is being loaded correctly:

map[APP_CLIENT_ID:2atM1d_KxIswqRn7uvusTe APP_CLIENT_SECRET:siuI0GDyOv9xD_htlQ5Tu APP_PASSWORD:super secret password APP_USER:gametrivia@jonathanmh.com MASTODON_SERVER:https://streamers.social]

Using the Mastodon API

This is great, we've moved our config loading to a file outside of our main.go to keep it a bit tidier and now we'll move on to making sure our API connection works as intended. In the examples of go-mastodon we can steal the following snippet:

  c := mastodon.NewClient(&mastodon.Config{
    Server:       "https://mstdn.jp",
    ClientID:     "client-id",
    ClientSecret: "client-secret",
  })
  err := c.Authenticate(context.Background(), "your-email", "your-password")
  if err != nil {
    log.Fatal(err)
  }
  timeline, err := c.GetTimelineHome(context.Background(), nil)
  if err != nil {
    log.Fatal(err)
  }
  for i := len(timeline) - 1; i >= 0; i-- {
    fmt.Println(timeline[i])
  }

and we can amend it to use our config like this:

  c := mastodon.NewClient(&mastodon.Config{
    Server:       envs["MASTODON_SERVER"],
    ClientID:     envs["APP_CLIENT_ID"],
    ClientSecret: envs["APP_CLIENT_SECRET"],
  })

  err := c.Authenticate(context.Background(), envs["APP_USER"], envs["APP_PASSWORD"])

remember to also import "github.com/mattn/go-mastodon" and then we can run:

go mod tidy
go run .

Which should no emit any errors or output since your bots timeline is empty. If you have manually posted anything, the posts should be listed in your terminal. If your credentials are wrong of if there are any bugs in your code, it should show something like the following error:

2023/01/22 17:41:18 bad authorization: 401 Unauthorized: invalid_client
exit status 1

Posting to the Mastodon API

Finally, we'll post our example post, let's create a file called posts/example.toml and add a picture called posts/owl.jpg and try to post it to the timeline.

example.toml:

text = "The majestic owl, owling about in its natural habitat."
tags = ['owl', 'nature', 'photography']
asset = 'owl.jpg'
assetAlt = 'an owl'
credits = 'https://unsplash.com/photos/miEWeTPYsFE'

Now we amend the main.go file to include the following:

  post, err := LoadPost()
  if err != nil {
    log.Fatal(err)
  }

  var media mastodon.Media
  file, err := os.Open("./posts/" + post.Asset)
  if err != nil {
    log.Fatal(err)
  }

  media = mastodon.Media{File: file, Description: post.AssetAlt}
  attachment, err := c.UploadMediaFromMedia(context.Background(), &media)
  var attachmentIDs []mastodon.ID

  attachmentIDs = append(attachmentIDs, attachment.ID)

  finalText := post.Text + "\n"

  for i := 0; i < len(post.Tags); i++ {
    finalText = finalText + "#" + post.Tags[i] + " "
  }

  finalText = finalText + "\n" + "Credits: " + post.Credits

  toot := mastodon.Toot{
    Status:   finalText,
    MediaIDs: attachmentIDs,
  }

  fmt.Printf("About to publish: %#v\n", toot)

In this snippet we first upload the attachment for the post and save the attachment ID, then we alter the text and append each hashtag prefixed with a # and lastly add the credits.

Then we provide both to the mastodon.Toot type struct, made available by the library.

If you receive no error, you can add the final lines to post to your bot account:

status, err := c.PostStatus(context.Background(), &toot)

if err != nil {
  log.Fatalf("%#v\n", err)
}

Now you should be able to see your post on your bot account:

bot account published post

To post periodically I use a cronjob to run the application inside a docker container in production once per day. This takes a bit of planning, but it deploys automatically to dokku on merges to the main branch, which I will cover in the future.

Thank you so much for reading, drop me a link on mastodon to which wonderful bots you're creating!

Tagged with: #go #mastodon #automation

Thank you for reading! If you have any comments, additions or questions, please tweet or toot them at me!