Learning Go by examples: part 4 - Create a Bot for Discord in Go

Aurélie Vache - Aug 4 '21 - - Dev Community

After created an HTTP REST API server and our first CLI (Command Line Interface) application in Go, what can we do now?
What if we create our own bot for Discord? ^^

I use Discord more and more everyday with my communities, so why not creating a bot that display our favorites Gophers to my friends? :-)

Discord prerequisites

If you don't know Discord yet, it's like Slack, Teams or Mattermost, a good free alternative.

First, if you don't have a Discord account, you need to create one ;-).

Enable developer mode

In order to have sufficient rights, you need to enable developer mode in your Discord account. For that, in your Discord application, click on User Settings button:

developer mode

Then, click on Advanced and then enable Developer Mode:
enable developer mode

Create a Discord application

Go to https://discord.com/developers/applications/, then on New Application button, and then name your app and click on Create button:

create app

don't be afraid

You can now fill the description and add an icon to your freshly created app.

bot description

Create a Bot

Click on Bot menu and then on Add Bot button.

create a bot

This action allows to make visible your app/your bot on Discord.

The message "A wild bot has appeared!" should be appear in your interface:

A wild bot has appeared

Now go in OAuth2 menu and click on Copy button in order to get Client ID information:

OAuth2 menu

Generate the Bot invite link

In order to link our Bot to one of our Discord server, we need to generate an invite link, with the CLIENT ID we copied:



https://discord.com/api/oauth2/authorize?client_id=<CLIENT-ID>&permissions=8&scope=bot


Enter fullscreen mode Exit fullscreen mode

When we go to this URL, a connection window appears:

Bot invite

Select the Discord server (that you created or have admin rights) and then click on Continue button and then confirm that you allow permissions to your Bot.

Your app is now authorized to do "things" on your Discord server :-).

You should now see your Bot (like others members) in your Discord server:

A new Bot in our Discord server

Save the token

There is one last thing to do so that our Go application can connect to the Discord server: we need a token.

For that, go back in the Discord application developers website, then click on Bot menu and then click on Copy button in order to copy the token (and save it somewhere):

save our token

You will have to paste this token further in this article ;-).

It's time to create our awesome Bot!

Yoda Gopher

Ok, everything have been configured in Discord, you know I love concrete things, so it's time to play with Go now and code our simple (but so cute) Bot! :-D

Initialization

We created our Git repository in the previous article, so now we just have to retrieve it locally:



$ git clone https://github.com/scraly/learning-go-by-examples.git
$ cd learning-go-by-examples


Enter fullscreen mode Exit fullscreen mode

We will create a folder go-gopher-bot-discord for our CLI application and go into it:



$ mkdir go-gopher-bot-discord
$ cd go-gopher-bot-discord


Enter fullscreen mode Exit fullscreen mode

Now, we have to initialize Go modules (dependency management):



$ go mod init github.com/scraly/learning-go-by-examples/go-gopher-bot-discord
go: creating new go.mod: module github.com/scraly/learning-go-by-examples/go-gopher-bot-discord


Enter fullscreen mode Exit fullscreen mode

This will create a go.mod file like this:



module github.com/scraly/learning-go-by-examples/go-gopher-bot-discord

go 1.16


Enter fullscreen mode Exit fullscreen mode

Before to start our super CLI application, as good practices, we will create a simple code organization.

Create the following folders organization:



.
├── README.md
├── bin
├── go.mod


Enter fullscreen mode Exit fullscreen mode

That's it? Yes, the rest of our code organization will be created shortly ;-).

What do we want?

Wait a minute, what do we want for our Bot?

We want a bot for Discord which will:

  • Display a cute Gopher, when we will enter !gopher in our favorite Discord server(s)
  • Display the list of available Gophers, when we will enter !gophers
  • Display a random Gopher, when we will enter !random

DiscordGo

In order to do that, we need a Client that interact with Go servers. Don't forget that a lot of useful and awesome libraries exists in Go, we don't have to reinvent the wheel, so we will use DiscordGo library.

DiscordGo

DiscordGo is a Go package that provides low level bindings to the Discord chat client API. DiscordGo has nearly complete support for all of the Discord API endpoints, websocket interface, and voice interface.
Cool!

Let's install DiscordGo in order to use it in our code:



$ go get github.com/bwmarrin/discordgo


Enter fullscreen mode Exit fullscreen mode

Good, now we can create a main.go file and copy/paste the following code into it.

Go code is organized into packages. So, first, we initialize the package, called main, and all dependencies/librairies we need to import and use in our main file:



package main

import (
    "encoding/json"
    "flag"
    "fmt"
    "io/ioutil"
    "net/http"
    "os"
    "os/signal"
    "strings"
    "syscall"

    "github.com/bwmarrin/discordgo"
)


Enter fullscreen mode Exit fullscreen mode

Then, we init the Token variable which will be a needed parameter for our Bot app, the KuteGo API URL and the init() function that define we need the token:



// Variables used for command line parameters
var (
    Token string
)

const KuteGoAPIURL = "https://kutego-api-xxxxx-ew.a.run.app"

func init() {
    flag.StringVar(&Token, "t", "", "Bot Token")
    flag.Parse()
}


Enter fullscreen mode Exit fullscreen mode

And the main() function that create a Discord session, register to MessageCreate events and run our Bot:



func main() {

    // Create a new Discord session using the provided bot token.
    dg, err := discordgo.New("Bot " + Token)
    if err != nil {
        fmt.Println("error creating Discord session,", err)
        return
    }

    // Register the messageCreate func as a callback for MessageCreate events.
    dg.AddHandler(messageCreate)

    // In this example, we only care about receiving message events.
    dg.Identify.Intents = discordgo.IntentsGuildMessages

    // Open a websocket connection to Discord and begin listening.
    err = dg.Open()
    if err != nil {
        fmt.Println("error opening connection,", err)
        return
    }

    // Wait here until CTRL-C or other term signal is received.
    fmt.Println("Bot is now running. Press CTRL-C to exit.")
    sc := make(chan os.Signal, 1)
    signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM, os.Interrupt, os.Kill)
    <-sc

    // Cleanly close down the Discord session.
    dg.Close()
}


Enter fullscreen mode Exit fullscreen mode

Next, we need to define and implement a Gopher struct and the messageCreate function that will be called each time a message will be send in our Discord server.



type Gopher struct {
    Name string `json: "name"`
}

// This function will be called (due to AddHandler above) every time a new
// message is created on any channel that the authenticated bot has access to.
func messageCreate(s *discordgo.Session, m *discordgo.MessageCreate) {

    // Ignore all messages created by the bot itself
    // This isn't required in this specific example but it's a good practice.
    if m.Author.ID == s.State.User.ID {
        return
    }

    if m.Content == "!gopher" {

        //Call the KuteGo API and retrieve our cute Dr Who Gopher
        response, err := http.Get(KuteGoAPIURL + "/gopher/" + "dr-who")
        if err != nil {
            fmt.Println(err)
        }
        defer response.Body.Close()

        if response.StatusCode == 200 {
            _, err = s.ChannelFileSend(m.ChannelID, "dr-who.png", response.Body)
            if err != nil {
                fmt.Println(err)
            }
        } else {
            fmt.Println("Error: Can't get dr-who Gopher! :-(")
        }
    }

    if m.Content == "!random" {

        //Call the KuteGo API and retrieve a random Gopher
        response, err := http.Get(KuteGoAPIURL + "/gopher/random/")
        if err != nil {
            fmt.Println(err)
        }
        defer response.Body.Close()

        if response.StatusCode == 200 {
            _, err = s.ChannelFileSend(m.ChannelID, "random-gopher.png", response.Body)
            if err != nil {
                fmt.Println(err)
            }
        } else {
            fmt.Println("Error: Can't get random Gopher! :-(")
        }
    }

    if m.Content == "!gophers" {

        //Call the KuteGo API and display the list of available Gophers
        response, err := http.Get(KuteGoAPIURL + "/gophers/")
        if err != nil {
            fmt.Println(err)
        }
        defer response.Body.Close()

        if response.StatusCode == 200 {
            // Transform our response to a []byte
            body, err := ioutil.ReadAll(response.Body)
            if err != nil {
                fmt.Println(err)
            }

            // Put only needed informations of the JSON document in our array of Gopher
            var data []Gopher
            err = json.Unmarshal(body, &data)
            if err != nil {
                fmt.Println(err)
            }

            // Create a string with all of the Gopher's name and a blank line as separator
            var gophers strings.Builder
            for _, gopher := range data {
                gophers.WriteString(gopher.Name + "\n")
            }

            // Send a text message with the list of Gophers
            _, err = s.ChannelMessageSend(m.ChannelID, gophers.String())
            if err != nil {
                fmt.Println(err)
            }
        } else {
            fmt.Println("Error: Can't get list of Gophers! :-(")
        }
    }
}


Enter fullscreen mode Exit fullscreen mode

Let's dig in the code, step by step

I know, the last code block is huge and there are little tips and mechanisms to know, so let's dig in code blocks, step by step and slowly :-).

Ignore all messages created by the Bot

Everytime a message is sent in the Discord server, our function is executed, so the first thing is to tell that we ignore all messages created by the Bot itself:



    // Ignore all messages created by the bot itself
    // This isn't required in this specific example but it's a good practice.
    if m.Author.ID == s.State.User.ID {
        return
    }


Enter fullscreen mode Exit fullscreen mode

If message sent is equals to !gopher

If !gopher text message is sent in the Discord server, we ask dr-who Gopher to the KuteGo API, we close the response body and then if everything is OK, we send a message with embedded our cute Doctor Who Gopher.



    if m.Content == "!gopher" {

        //Call the KuteGo API and retrieve our cute Dr Who Gopher
        response, err := http.Get(KuteGoAPIURL + "/gopher/" + "dr-who")
        if err != nil {
            fmt.Println(err)
        }
        defer response.Body.Close()

        if response.StatusCode == 200 {
            _, err = s.ChannelFileSend(m.ChannelID, "dr-who.png", response.Body)
            if err != nil {
                fmt.Println(err)
            }
        } else {
            fmt.Println("Error: Can't get dr-who Gopher! :-(")
        }
    }


Enter fullscreen mode Exit fullscreen mode

KuteGo API

As you have maybe seen, we call a URL started with "https://kutego-api-" in our application, but what is it?

In reality, it's a REST API named KuteGo API created by my friend Gaëlle Acas. This API plays with my Gophers GitHub repository and is hosted in a private Google Cloud Run.



const KuteGoAPIURL = "https://kutego-api-xxxxxx-ew.a.run.app"


Enter fullscreen mode Exit fullscreen mode

So if you want to use it, you can install it locally (or wherever you want) and change kutego-api URL to localhost:8080 ;-).

If message sent is equals to !random

If !random text message is sent in the Discord server, we ask to the KuteGo API a random Gopher, we close the response body and then if everything is OK, we send a message with embedded our cute random Gopher. Surprise!



    if m.Content == "!random" {

        //Call the KuteGo API and retrieve a random Gopher
        response, err := http.Get(KuteGoAPIURL + "/gopher/random/")
        if err != nil {
            fmt.Println(err)
        }
        defer response.Body.Close()

        if response.StatusCode == 200 {
            _, err = s.ChannelFileSend(m.ChannelID, "random-gopher.png", response.Body)
            if err != nil {
                fmt.Println(err)
            }
        } else {
            fmt.Println("Error: Can't get random Gopher! :-(")
        }
    }


Enter fullscreen mode Exit fullscreen mode

If message sent is equals to !gophers

Let's attack to JSON parsing :-D.

In Golang, when you need to display informations contained in a JSON object, several new words appear: marshal and unmarshal.

Go Marshal

Unmarshal is the way that turn a JSON document into a Go struct.
Marshal is the opposite: we turn on Go struct to JSON document.

So when we unmarshal a JSON document, we transform it in a structured data that we can access easily. If a document doesn't fit into the structure it will throw an error.

So, in the following code block:

  • we initialize a struct named Gopher that contains Name and will match with the word name in a JSON document
  • we call /gophers route from KuteGo API
  • we read the response body (in order to get an array of byte)
  • we close the response body (a good practice seen in the previous article ;-))
  • we create an array of Gopher (our Go struct with only information we want to display/handle)
  • we put the JSON document in our array of Gopher
  • we create a list of gophers with only the name of all existing gophers
  • we send a message in the Discord server with this list of gophers


type Gopher struct {
    Name string `json: "name"`
}

...

    if m.Content == "!gophers" {

        //Call the KuteGo API and display the list of available Gophers
        response, err := http.Get(KuteGoAPIURL + "/gophers/")
        if err != nil {
            fmt.Println(err)
        }
        defer response.Body.Close()

        if response.StatusCode == 200 {
            // Transform our response to a []byte
            body, err := ioutil.ReadAll(response.Body)
            if err != nil {
                fmt.Println(err)
            }

            // Put only needed informations of the JSON document in our array of Gopher
            var data []Gopher
            err = json.Unmarshal(body, &data)
            if err != nil {
                fmt.Println(err)
            }

            // Create a string with all of the Gopher's name and a blank line as separator
            var gophers strings.Builder
            for _, gopher := range data {
                gophers.WriteString(gopher.Name + "\n")
            }

            // Send a text message with the list of Gophers
            _, err = s.ChannelMessageSend(m.ChannelID, gophers.String())
            if err != nil {
                fmt.Println(err)
            }
        } else {
            fmt.Println("Error: Can't get list of Gophers! :-(")
        }
    }


Enter fullscreen mode Exit fullscreen mode

Wait... strings.Builder what is it?

In Go, like others languages, when you want to concatenate and build strings, several ways to do that exists.

The easiest way is to simply concatenate strings with the + operator like this:



package main

import (
    "fmt"
    "strconv"
)

func main() {
    str := "my string"
    str += " and numbers: "

    for i:=0;i<10;i++ {
      // convert int to string
      str += strconv.Itoa(i)
    }

    fmt.Println(str)
}


Enter fullscreen mode Exit fullscreen mode

Easy but not very efficient when we concatenate a lot of strings together ;-).

Another (but old) solution is to use bytes.Buffer and then convert it to a string once you have concatenated everything:



package main

import (
    "fmt"
    "bytes"
    "strconv"
)

func main() {

    var buffer bytes.Buffer

    buffer.WriteString("my string")
    buffer.WriteString(" and numbers: ")

    for i:=0;i<10;i++ {
        buffer.WriteString(strconv.Itoa(i))
    }

    fmt.Println(buffer.String())
}


Enter fullscreen mode Exit fullscreen mode

And, yes, our choosen solution, recommanded and new since Go 1.10 is to use strings.Builder:



package main

import (
    "fmt"
    "strconv"
    "strings"
)

func main() {

    var sb strings.Builder

    sb.WriteString("my string")
    sb.WriteString(" and numbers: ")

    for i:=0;i<10;i++ {
        sb.WriteString(strconv.Itoa(i))
    }

    fmt.Println(sb.String())
}


Enter fullscreen mode Exit fullscreen mode

"A Builder is used to efficiently build a string using Write methods. It minimizes memory copying. The zero value is ready to use."

Perfect! :-)

Test it!

After code explanation, it's time to test our Bot!

First, you can export the token:



$ export BOT_TOKEN=<your bot token>


Enter fullscreen mode Exit fullscreen mode

Let's run locally our Bot right now:



$ go run main.go -t $BOT_TOKEN
Bot is now running.  Press CTRL-C to exit.


Enter fullscreen mode Exit fullscreen mode

Your Bot is running in your local machine and is now connected to Discord.

Let's enter several messages in our Discord server:

!gopher command

Awesome, when we enter the command !gopher, our Who Gopher appear!

Build it!

Your application is now ready, you can build it.
For that, like the previous articles, we will use Taskfile in order to automate our common tasks.

So, for this app too, I created a Taskfile.yml file with this content:



version: "3"

tasks:
    build:
        desc: Build the app
        cmds:
        - GOFLAGS=-mod=mod go build -o bin/gopher-bot-discord main.go 

    run: 
        desc: Run the app
        cmds:
        - GOFLAGS=-mod=mod go run main.go -t $BOT_TOKEN

    bot:
        desc: Execute the bot
        cmds:
        - ./bin/gopher-bot-discord -t $BOT_TOKEN


Enter fullscreen mode Exit fullscreen mode

Thanks to this, we can build our app easily:



$ task build
task: [build] GOFLAGS=-mod=mod go build -o bin/gopher-bot-discord main.go


Enter fullscreen mode Exit fullscreen mode

Let's test it again with our fresh executable binary:



$ ./bin/gopher-bot-discord -t $BOT_TOKEN
Bot is now running.  Press CTRL-C to exit.


Enter fullscreen mode Exit fullscreen mode

or through task:



$ task bot
task: [bot] ./bin/gopher-bot-discord -t $BOT_TOKEN
Bot is now running.  Press CTRL-C to exit.


Enter fullscreen mode Exit fullscreen mode

!gophers

Awesome, the !gophers command works too, we now know all the existings Gophers :-).

!random

And, we've got a random Gopher!

Conclusion

As you have seen in this article and previous articles, it's possible to create applications in Go: CLI, REST API... but also fun apps like a Discord Bot! :-)

All the code of our Bot in Go is available in: https://github.com/scraly/learning-go-by-examples/tree/main/go-gopher-bot-discord

If you are interested by creating your own Bot for Discord in Go, several examples with DiscordGo are available.

In the following articles we will create others kind/types of applications in Go.

Hope you'll like it.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .