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.
find your client key and client 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:
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!