mager.co

Building a Discord bot with Go on Google Cloud's free tier

March 13, 2022
Google CloudTutorialDiscord

If you spend any time on Discord, you know that bots are everywhere. But how does a bot get created, and where does it get hosted?

I’ve been exploring Discord bots for my Floor Report app. The bot I created listens for a specific command, like f boredapeyachtclub, and returns the floor price for that particular collection on OpenSea.

The code for this is surprisingly simple, and we’ll walk though a basic app written in Go that you can customize for your needs. You can also host this app on Google Cloud’s free tier. We’ll be using Cloud Run and Cloud Scheduler.

Prerequisites

  • Sign up for Google Cloud, and create a billing account. You will most likely get $300 of free credit, but we actually won’t even dip into pool because your app will be very lightweight.
  • Terminal app (on Mac, Applications > Utilities > Terminal)
  • VSCode or other text editor

Getting Started

The first step is to login to Google Cloud and create a project. Open up your terminal and run:

gcloud auth login

This will open up a browser window so you can login. Next, create the project:

gcloud projects create my-example-project-999

The ouput should be something like this:

➜  gcloud projects create my-example-project-999
Create in progress for [https://cloudresourcemanager.googleapis.com/v1/projects/my-example-project-999].
Waiting for [operations/cp.5459862736956130717] to finish...done.
Enabling service [cloudapis.googleapis.com] on project [my-example-project-999]...
Operation "operations/acf.p2-8759988169-05a79294-ae4a-4885-810c-1be5af3e50cb" finished successfully.

Once this is done, create a new folder wherever you have your code called my-example-project-999 or anything you want.

mkdir my-example-project-999
cd my-example-project-999

Next, let’s create a Go module:

go mod init github.com/YOUR_GITHUB_USERNAME/my-example-project-999

Let’s also create our main.go file and open VSCode.

touch main.go
code .

Add the following to your main.go file:

package main

import (
	"fmt"

	"go.uber.org/fx"
)

func main() {
	fx.New(
		fx.Invoke(Register),
	).Run()
}

func Register(
	lc fx.Lifecycle,
) {
	fmt.Println("Hello, World!")
}

Run go mod tidy && go run main.co and you should see this:

➜  go mod tidy && go run main.go
go: finding module for package go.uber.org/fx
go: found go.uber.org/fx in go.uber.org/fx v1.16.0
[Fx] PROVIDE	fx.Lifecycle <= go.uber.org/fx.New.func1()
[Fx] PROVIDE	fx.Shutdowner <= go.uber.org/fx.(*App).shutdowner-fm()
[Fx] PROVIDE	fx.DotGraph <= go.uber.org/fx.(*App).dotGraph-fm()
[Fx] INVOKE		main.Register()
Hello, World!
[Fx] RUNNING

So what just happened here?

Let’s run by the file line by line:

  • First, we declared the package name main (this is standard in Go for the entrypoint to your app).
  • Next we imported a few libraries, the standard fmt and Go-fx from Uber at go.uber.org/fx. I’ve used this library before, so I won’t go into much detail here, but it makes your application super moudlar and it’s easy to inject dependencies.
  • Our main function initializes Fx and invokes the Register function.
  • The Register function just prints out, “Hello, World!“.

Adding a logger

Let’s create our first fx module inside our app for a better logger.

Create a folder called logger and a file called logger.go inside. Paste this into logger.go:

package logger

import (
	"go.uber.org/zap"
)

// ProvideLogger provides a zap logger
func ProvideLogger() *zap.SugaredLogger {
	logger, _ := zap.NewProduction()
	return logger.Sugar()
}

var Options = ProvideLogger

We’re using another open-source library from Uber called zap. It makes logs pretty and structured.

Let’s now include this provider in our main.go:

package main

import (
	"github.com/mager/my-example-project-999/logger"
	"go.uber.org/fx"
	"go.uber.org/zap"
)

func main() {
	fx.New(
		fx.Provide(
			logger.Options,
		),
		fx.Invoke(Register),
	).Run()
}

func Register(
	lc fx.Lifecycle,
	logger *zap.SugaredLogger,
) {
	logger.Infow("Hello, World!", "foo", "bar")
}

Here, we’re adding a new function called fx.Provide, which takes the logger provider we just added. Another thing you’ll notice is that our Register function now includes the logger. We replaced fmt.Println with logger.Infow.

Let’s restart the app now, but first, we should make a shortcut so we don’t have to type go mod tidy & go run main.go. Create a new file at the root called Makefile:

dev:
	go mod tidy && go run main.go

Now we can just run make dev:

➜  make dev
go mod tidy && go run main.go
[Fx] PROVIDE	*zap.SugaredLogger <= github.com/mager/my-example-project-999/logger.ProvideLogger()
[Fx] PROVIDE	fx.Lifecycle <= go.uber.org/fx.New.func1()
[Fx] PROVIDE	fx.Shutdowner <= go.uber.org/fx.(*App).shutdowner-fm()
[Fx] PROVIDE	fx.DotGraph <= go.uber.org/fx.(*App).dotGraph-fm()
[Fx] INVOKE		main.Register()
{"level":"info","ts":1642341003.654142,"caller":"my-example-project-999/main.go:22","msg":"Hello, World!","foo":"bar"}
[Fx] RUNNING

The logger is much better now, and includes the line of code where it was called. You’ll also noticed that we have sturctured logging with the foo & bar params.

Adding a router & route handler

The next thing we’re going to create is a router & handler for an API endpoint. We need a health check endpoint for our service, and we’ll use this to keep the bot alive once it’s deployed to Google Cloud Run.

Create a new folder called router with router.go inside.

package router

import (
	"context"
	"net/http"

	"github.com/gorilla/mux"
	"go.uber.org/fx"
	"go.uber.org/zap"
)

// ProvideRouter provides a gorilla mux router
func ProvideRouter(lc fx.Lifecycle, logger *zap.SugaredLogger) *mux.Router {
	var router = mux.NewRouter()

	router.Use(jsonMiddleware)

	lc.Append(
		fx.Hook{
			OnStart: func(context.Context) error {
				addr := ":8080"
				logger.Info("Listening on ", addr)

				go http.ListenAndServe(addr, router)

				return nil
			},
		},
	)

	return router
}

// jsonMiddleware makes sure that every response is JSON
func jsonMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Add("Content-Type", "application/json")
		next.ServeHTTP(w, r)
	})
}

var Options = ProvideRouter

Even though it looks like there’s a lot going on here, it’s pretty basic:

  • Initialize the router
  • Inject some middleware to add a JSON header
  • Create a new fx lifecycle method to start a server and listen on port 8080 when the app starts up

Let’s add the router to main.go now:

package main

import (
	"github.com/gorilla/mux"
	"github.com/mager/my-example-project-999/logger"
	"github.com/mager/my-example-project-999/router"
	"go.uber.org/fx"
	"go.uber.org/zap"
)

func main() {
	fx.New(
		fx.Provide(
			logger.Options,
			router.Options,
		),
		fx.Invoke(Register),
	).Run()
}

func Register(
	lc fx.Lifecycle,
	logger *zap.SugaredLogger,
	router *mux.Router,
) {
	logger.Infow("Hello, World!", "foo", "bar")
}

Next, let’s create a folder called handler with handler.go inside:

package handler

import (
	"encoding/json"
	"net/http"

	"github.com/gorilla/mux"
	"go.uber.org/zap"
)

type Resp struct {
	Status string `json:"status"`
}

// Handler struct for HTTP requests
type Handler struct {
	logger *zap.SugaredLogger
	router *mux.Router
}

// New creates a Handler struct
func New(
	logger *zap.SugaredLogger,
	router *mux.Router,
) *Handler {
	h := Handler{logger, router}
	h.registerRoutes()
	return &h
}

// RegisterRoutes registers all the routes for the route handler
func (h *Handler) registerRoutes() {
	h.router.HandleFunc("/health", h.health).Methods("GET")
}

func (h *Handler) health(w http.ResponseWriter, r *http.Request) {
	var resp = Resp{Status: "OK"}

	json.NewEncoder(w).Encode(resp)
}

Here’s the low down on the handler:

  • We intialize a Resp struct, which will be the response of our endpoint.
  • The Handler struct includes all the dependencies we’ll soon pass in from main.go.
  • We initialize the Handler struct with the logger & router, then call the registerRoutes function.
  • registerRoutes defines all of the routes in our handler. Our handler is called health, and will be a GET request to /health.
  • The health handler just responds with OK.

Let’s update main.go now:

package main

import (
	"github.com/gorilla/mux"
	"github.com/mager/my-example-project-999/handler"
	"github.com/mager/my-example-project-999/logger"
	"github.com/mager/my-example-project-999/router"
	"go.uber.org/fx"
	"go.uber.org/zap"
)

func main() {
	fx.New(
		fx.Provide(
			logger.Options,
			router.Options,
		),
		fx.Invoke(Register),
	).Run()
}

func Register(
	lc fx.Lifecycle,
	logger *zap.SugaredLogger,
	router *mux.Router,
) {
	logger.Infow("Hello, World!", "foo", "bar")

	handler.New(logger, router)
}

Run make dev and open a new terminal window. Run curl localhost:8080/health and you should get a response.

Authenticating the bot

To give the bot access to your application, you need a bot token. Head over to Discord’s developer portal and create an application.

Next, add a bot:

Then, copy the token:

Next, run the following command in your terminal:

echo 'export MYEXAMPLEPROJECT_DISCORDAUTHTOKEN="Paste the token you just copied here"' >> ~/.zprofile && source ~/.zprofi
le

This will store your Discord bot token as an environment variable that your Go application will consume.

We’re going to use Kelsey Hightower’s envconfig package because it’s easy to use and straightforward to understand.

Create a folder called config with config.go inside:

package config

import (
	"log"

	"github.com/kelseyhightower/envconfig"
)

type Config struct {
	DiscordBotToken string
}

func ProvideConfig() Config {
	var cfg Config
	err := envconfig.Process("myexampleproject", &cfg)
	if err != nil {
		log.Fatal(err.Error())
	}
	return cfg
}

var Options = ProvideConfig

Next, let’s import it in main.go like we did with the other providers before:

package main

import (
	"github.com/gorilla/mux"
	"github.com/mager/my-example-project-999/config"
	"github.com/mager/my-example-project-999/handler"
	"github.com/mager/my-example-project-999/logger"
	"github.com/mager/my-example-project-999/router"
	"go.uber.org/fx"
	"go.uber.org/zap"
)

func main() {
	fx.New(
		fx.Provide(
			config.Options,
			logger.Options,
			router.Options,
		),
		fx.Invoke(Register),
	).Run()
}

func Register(
	lc fx.Lifecycle,
	cfg config.Config,
	logger *zap.SugaredLogger,
	router *mux.Router,
) {
	logger.Infow("Hello, World!", "foo", "bar")

	handler.New(logger, router)
}

Next, we’re going to create a provider from the discordgo library from bwmarrin. Create a folder called discord with discord.go inside:

package discord

import (
	"fmt"
	"log"

	"github.com/bwmarrin/discordgo"
	"github.com/mager/my-example-project-999/config"
)

func ProvideDiscord(cfg config.Config) *discordgo.Session {
	token := fmt.Sprintf("Bot %s", cfg.DiscordBotToken)
	dg, err := discordgo.New(token)
	if err != nil {
		log.Fatalf("Failed to create client: %v", err)
	}

	return dg
}

var Options = ProvideDiscord

Here, we fetch the Discord bot token from the config and initialize the Discord session (dg). Let’s update main.go next:

package main

import (
	"context"
	"fmt"
	"log"

	"github.com/bwmarrin/discordgo"
	"github.com/gorilla/mux"
	"github.com/mager/my-example-project-999/bot"
	"github.com/mager/my-example-project-999/config"
	"github.com/mager/my-example-project-999/discord"
	"github.com/mager/my-example-project-999/handler"
	"github.com/mager/my-example-project-999/logger"
	"github.com/mager/my-example-project-999/router"
	"go.uber.org/fx"
	"go.uber.org/zap"
)

func main() {
	fx.New(
		fx.Provide(
			config.Options,
			discord.Options,
			logger.Options,
			router.Options,
		),
		fx.Invoke(Register),
	).Run()
}

func Register(
	lc fx.Lifecycle,
	cfg config.Config,
	dg *discordgo.Session,
	logger *zap.SugaredLogger,
	router *mux.Router,
) {
	// TODO: Start the bot

	handler.New(logger, router)

	lc.Append(fx.Hook{
		OnStop: func(ctx context.Context) error {
			logger.Info("Closing Discord session")
			defer dg.Close()
			if err != nil {
				logger.Errorf("Failed to close Discord session: %v", err)
			}
			return err
		},
	})
}

In the Register function, we introduce an `fx` lifecycle hook to gracefully close down the session when the app shuts down. We will add the function to start the bot next, but first, try running `make dev` again, and you should see something like this:

```sh
[Fx] PROVIDE config.Config <= github.com/mager/my-example-project-999/config.ProvideConfig()
[Fx] PROVIDE *discordgo.Session <= github.com/mager/my-example-project-999/discord.ProvideDiscord()
[Fx] PROVIDE *zap.SugaredLogger <= github.com/mager/my-example-project-999/logger.ProvideLogger()
[Fx] PROVIDE *mux.Router <= github.com/mager/my-example-project-999/router.ProvideRouter()
[Fx] PROVIDE fx.Lifecycle <= go.uber.org/fx.New.func1()
[Fx] PROVIDE fx.Shutdowner <= go.uber.org/fx.(*App).shutdowner-fm()
[Fx] PROVIDE fx.DotGraph <= go.uber.org/fx.(\*App).dotGraph-fm()
[Fx] INVOKE main.Register()
[Fx] HOOK OnStart github.com/mager/my-example-project-999/router.ProvideRouter.func1() executing (caller: github.com/mager/my-example-project-999/router.ProvideRouter)
{"level":"info","ts":1648301402.36386,"caller":"router/router.go:22","msg":"Listening on :8080"}
[Fx] HOOK OnStart github.com/mager/my-example-project-999/router.ProvideRouter.func1() called by github.com/mager/my-example-project-999/router.ProvideRouter ran successfully in 237.458µs
[Fx] RUNNING

And when you hit Ctrl+C, you’ll see this:

^C[Fx] INTERRUPT
[Fx] HOOK OnStop main.Register.func1() executing (caller: main.Register)
{"level":"info","ts":1648301529.9010181,"caller":"my-example-project-999/main.go:52","msg":"Closing Discord session"}
[Fx] HOOK OnStop main.Register.func1() called by main.Register ran successfully in 145.875µs
make: *** [dev] Error 1

Connecting to the Discord gateway

Now that our app has access to the Discord session object, we can connect to the gateway.

Let’s create a folder called bot with a bot.go inside:

package bot

import (
	"github.com/bwmarrin/discordgo"
	"go.uber.org/zap"
)

func Start(
	dg *discordgo.Session,
	logger *zap.SugaredLogger,
) {
	// Open a websocket connection to Discord and begin listening.
	wsErr := dg.Open()
	if wsErr != nil {
		logger.Errorw("error opening connection", "error", wsErr)
	}

	dg.AddHandler(func(s *discordgo.Session, r *discordgo.Ready) {
		logger.Infof("Logged in as: %v#%v", s.State.User.Username, s.State.User.Discriminator)
	})
}

Here, the Start function opens the websocket connection to the Discord Gateway and logs a message when the bot is online.

In main.go, let’s add a call to bot.Start

package main

import (
"context"
"fmt"
"log"

    "github.com/bwmarrin/discordgo"
    "github.com/gorilla/mux"
    "github.com/mager/my-example-project-999/bot"
    "github.com/mager/my-example-project-999/config"
    "github.com/mager/my-example-project-999/discord"
    "github.com/mager/my-example-project-999/handler"
    "github.com/mager/my-example-project-999/logger"
    "github.com/mager/my-example-project-999/router"
    "go.uber.org/fx"
    "go.uber.org/zap"

)

func main() {
fx.New(
fx.Provide(
config.Options,
discord.Options,
logger.Options,
router.Options,
),
fx.Invoke(Register),
).Run()
}

func Register(
lc fx.Lifecycle,
cfg config.Config,
dg *discordgo.Session,
logger *zap.SugaredLogger,
router \*mux.Router,
) {

    // Setup Discord Bot
    token := fmt.Sprintf("Bot %s", cfg.DiscordBotToken)
    dg, err := discordgo.New(token)
    if err != nil {
    	log.Fatalf("Failed to create client: %v", err)
    }

    bot.Start(dg, logger)

    handler.New(logger, router)

    lc.Append(fx.Hook{
    	OnStop: func(ctx context.Context) error {
    		logger.Info("Closing Discord session")
    		defer dg.Close()
    		if err != nil {
    			logger.Errorf("Failed to close Discord session: %v", err)
    		}
    		return err
    	},
    })

}

MORE COMING SOON!

© 2022