[How To] Building a Simple Discord Bot using DiscordGo

Earlier this year I started trying out Golang as a new language to learn. Most of my prior experience is with Python, with a handful of other languages sprinkled in here & there.

My experience with Go so far has been good & I've been having fun learning something new. It's been easy enough to pick up & there have been quite a handful of things that I really appreciate about Go vs Python.

One of the best ways to learn something new is to find a good project to work on. Since I've done quite a bit historically with Python & building Webex chatbots - I figured one good way to learn Go would be trying to do something similar.

So in this blog post, I'll walk through how to build a simple Discord bot using a Golang module called DiscordGo. We'll use the same project idea as my previous Webex bot, where the bot will leverage the OpenWeather APIs to retrieve weather information.

Since I've been learning Go recently, I'll do my best to try to keep this simple - so that hopefully you can follow along if you're also new to Go 😊.

Code repo for the examples below can be found here


Setting up the Project

So first we'll run through some quick setup. If you're familiar enough with Go already, you can likely skip this step & just set up your project in the way you prefer.

I've created a new project folder called discord-weather-bot. So first we'll initialize our Go module using the command go mod init discord-weather-bot.

Then, I'll create a main.go in this folder, as well as a sub-folder named bot which will contain a bot.go file.

Once we're done, our initial project layout should look like this:

project-layout

We'll add on to this later, but this will work to start.

Next, we'll need to install the DiscordGo module, which we can do with one simple command: go get github.com/bwmarrin/discordgo

Generating API Keys

Next we'll need to get our API keys for OpenWeather & Discord.

OpenWeather is going to be the easier of the two - simply sign up for a free account here. Then jump over to your account page & create a new API key here. Free accounts are limited to only using their current weather API, which will be more than enough for our example.

Next, we'll look at the Discord side of things - which will be a few more hoops to jump though.

First we'll need to head over to the Discord Applications page & create a new application/integration.

new-app

We'll be prompted to provide a name for our app (Note, this isn't the name of our bot - we'll get to that shortly):

new-app-name

Once we finish that, we'll be taken to a page to add additional information about our app - like tags, a description, an icon, or links to app resources. If you plan to publish your app publicly, you'll want to invest some time here. However, since we'll just be building the bot as a personal project, we don't need to do anything here.

Next, we'll want to jump down to the Bot section under Settings. This is where we can set up some basics for our bot & get our bot API token.

side-menu

We'll get a quick confirmation prompt, where we'll click on Yes, do it!

add-bot-confirm

Now we have our bot created! We can change the name of our bot here, to be separate from our app name if we want.

new-bot

Scrolling down the page a bit - by default our bot is public, which means anyone could add it to their server. I'll switch this off for now, since this is intended to be a private example bot.

public-bot

In order for our bot to receive & process message content, we'll also need to enable the toggle for message content. Note that Discord says this is only allowed for servers under a certain size, after which they would need to be verified in order to use this intent. Since this bot will only be used as an example, we don't need to worry about that yet - but we will still need to enable the intent:

bot-intents

For now, this is all we need to set up on this page. Before we leave, we'll want to reset our bot access token - and save the token for later:

bot-token

Last but not least, we'll need to add our Discord bot to a server. I have a server in Discord that I use for testing, and I've created a private channel to experiment with this bot.

In order to do this, we'll need to create an OAuth authorization link that can be used to give the bot access.

Under our Settings, we'll click on OAuth2 > URL Generator.

oauth-url-gen-1

In the screenshot above, I've selected the Scope as bot. And checked off permissions for Read Messages, Send Messages, and Send Messages in Threads. For what we'll be doing in this example, that should be more than enough.

At the bottom of the screenshot, you can see that Discord will auto-generate a URL which we can now use to add the bot to our Discord Server.

If we visit that link while signed into Discord, we'll be prompted to add it to our server:

add-bot-to-server

We'll also be prompted to confirm the permissions the bot will have:

confirm-bot-perms

Now our bot has been successfully added to our Discord server, but will show as offline since we haven't built it yet! Let's get started with that.

Building the Bot - Basic Interaction

So we'll start off by building out just enough of our bot code for it to send messages to our server.

Collecting API Keys via Environment Variables

For the purpose of this project, I'll be storing both the Discord & OpenWeather API keys as environment variables. So well keep our main.go simple & just handle importing our API keys and kicking off the bot:

// snippet from: main.go
package main

import (
	"discord-weather-bot/bot"
	"log"
	"os"
)

func main() {
	// Load environment variables
	botToken, ok := os.LookupEnv("BOT_TOKEN")
	if !ok {
		log.Fatal("Must set Discord token as env variable: BOT_TOKEN")
	}
	openWeatherToken, ok := os.LookupEnv("OPENWEATHER_TOKEN")
	if !ok {
		log.Fatal("Must set Open Weather token as env variable: OPENWEATHER_TOKEN")
	}
	
    // Start the bot
	bot.BotToken = botToken
	bot.OpenWeatherToken = openWeatherToken
	bot.Run()
}

In the code above, we'll import os and log which we'll use to handle importing our environment variables & generating errors if this fails. We'll also import our bot folder with discord-weather-bot/bot.

For our main function, we'll start by trying to find our environment variables. For each of the variables, we'll use a format of <varname>, ok := os.LookupEnv("<env name>").

Using os.LookupEnv, we'll try and search for the environment variable by name - so in the first example looking for BOT_TOKEN. If the environment variable exists, it will be assigned to botToken - and ok will be True. Otherwise, we'll get no value for botToken, and ok will be False.

If either environment variable doesn't exist, we'll just print a log message reminding the user to set the variable. log.Fatal will also quit the program after displaying the log message.

Finally, assuming we have both of those API keys - we'll kick those keys over to the bot module & start the bot with bot.Run(). Let's hop over & build that piece now.

Connecting to Discord

Next, we'll start out bot code with enough to receive the variables being passed by main.go:

// snippet from: bot/bot.go
package bot

import (
	"fmt"
	"log"
	"os"
	"os/signal"
	"strings"

	"github.com/bwmarrin/discordgo"
)

var (
	OpenWeatherToken string
	BotToken         string
)

func Run() {
	// Not implemented yet
}

In the snippet above, we've defined two variables - OpenWeatherToken and BotToken. Note that both start with a capital letter, meaning that they have been exported & available to code outside this module - which is how we can update these values via main.go by setting bot.BotToken = botToken.

Okay - Let's get started with connecting our bot to Discord. We'll update our Run function to the following:

// snippet from: bot/bot.go

func Run() {
	// Create new Discord Session
	discord, err := discordgo.New("Bot " + BotToken)
	if err != nil {
		log.Fatal(err)
	}

	// Add event handler
	discord.AddHandler(newMessage)

	// Open session
	discord.Open()
	defer discord.Close()

	// Run until code is terminated
	fmt.Println("Bot running...")
	c := make(chan os.Signal, 1)
	signal.Notify(c, os.Interrupt)
	<-c

}

To begin with, we create a new discord session with discordgo.New() & passing our BotToken. Discord requires an HTTP Authorization header that contains Bot <token> or Bearer <token> for OAuth. The discordgo.New() function is just setting this header for us.

Next we'll register an event handler to our Discord client. In a moment, we'll create a new function called newMessage that will receive & process any new messages that are created in our channel. More to come in just a moment!

Now we can open our websocket tunnel to Discord via discord.Open(). If you're not familiar with websockets, I wrote a little about them when I built a Webex bot in this post. We'll also include a defer discord.Close(), which will ensure that we gracefully close the Discord session whenever the Run() function exits.

Lastly, we'll use Go's os/signal package to listen for any interrupt/process kill signals. This will allow our Discord bot to run in the background until we stop it. We do this by creating a new channel of type os.Signal where we will listen for termination signals. Then using signal.Notify, we tell the signal package where to send termination signals (our c channel) - and what signals we want to listen for (os.Interrupt). Then, using <-c, our program will hold until something is received on the channel.

An operator like <- is typically used for concurrency, where we have one function passing data to another via a channel. The <- or -> operator indicates which direction that data is being sent. So in this example, we're receiving data from channel c using the statement <-c. Now the tricky thing here, is that we're not assigning that input to another variable (for example, incomingSignal <- c), since we don't care what signal was sent - just that there was a signal received.

After we receive any termination signal, the code will continue to the end of the Run() function. This will then automatically execute our defer discord.Close() from earlier, which will tear down our Discord session.

Processing Incoming Messages

Okay, now that we have our Discord session establishment handled, let's build out our newMessage() function to handle receiving messages.

So for now, our code for this function will look like this:

// snippet from: bot/bot.go

func newMessage(discord *discordgo.Session, message *discordgo.MessageCreate) {

	// Ignore bot messaage
	if message.Author.ID == discord.State.User.ID {
		return
	}

	// Respond to messages
	switch {
	case strings.Contains(message.Content, "weather"):
		discord.ChannelMessageSend(message.ChannelID, "I can help with that!")
	case strings.Contains(message.Content, "bot"):
		discord.ChannelMessageSend(message.ChannelID, "Hi there!")
	}
}

Let's take a quick look at the code above.

The newMessage() function is our event handler that we are registering with our Discord client. It will need to receive two values - a pointer to our Discord session (*discordgo.Session), and the type of event we're listening for. In this case we're listening for new message creation events (*discordgo.MessageCreate). A full list of events that we could listen for are listed in the Discord Gateway Documentation.

First thing we'll do when processing the message is ensure that the bot ignores any message from itself. We wouldn't want our bot creating a loop by responding to it's own messages, which then generates a new message to respond to!

We accomplish this by checking the incoming message's Author ID (message.Author.ID) against our current Discord session's User ID (discord.State.User.ID). If these values match, then the incoming message was created by our bot code - so we'll just return and stop execution of this function.

If the incoming message is from another user, then we can go ahead and process it. There are a number of ways to do this depending on what you want to achieve. To keep things simple, I am using a quick switch statement to evaluate the message content.

Our bot then is listening for two keywords - "weather" and "bot". If the incoming message content contains either of those two words, our bot will respond with a message by using discord.ChannelMessageSend - and passing the channel ID from the incoming message (message.ChannelID) and our message.

A quick note: I used a switch/case here since it would be easier to add onto than a long list of if/else... but the example code here still isn't perfect. For instance, our second case is only looking for someone using the word bot. However this is a simple match, and would still catch on someone talking about a robot or a bottle - since both contain bot in them. If this was a production/public bot, we would want to clean that up a bit.

Testing

Okay, with that we can now give our bot a quick test.

Let's go ahead and start our bot with go run main.go. Don't forget to set your BOT_TOKEN & OPENWEATHER_TOKEN environment variables first! (Or comment out the code for the OPENWEATHER_TOKEN, since we're not using it just yet)

Here's my test run:

basic-interaction

As we can see, the bot responded just as we expected!

Another fun thing to point out, is that since we're using websockets for the connection (aka Discord Gateway) - our bot can also register presence events. So as long as our bot is connected, it will actually show as online:

bot-presence

Adding Basic Bot Commands

So far we've gotten our bot connected to Discord, and responding to regular chat messages. Now we'll focus on actually adding functionality to our bot by leveraging the OpenWeather API.

For now, we'll implement this with very simple command matching. In a future blog post, I'll be showing how to accomplish this with Discord slash commands.

So first we'll modify our existing switch statement in the newMessage() function. We'll provide a little help text with our response to someone mentioning weather - and create a new command by catching messages with !zip:

// snippet from: bot/bot.go

// Respond to messages
	switch {
	case strings.Contains(message.Content, "weather"):
		discord.ChannelMessageSend(message.ChannelID, "I can help with that! Use '!zip <zip code>'")
	case strings.Contains(message.Content, "bot"):
		discord.ChannelMessageSend(message.ChannelID, "Hi there!")
	case strings.Contains(message.Content, "!zip"):
		currentWeather := getCurrentWeather(message.Content)
		discord.ChannelMessageSendComplex(message.ChannelID, currentWeather)
	}

Under the !zip command, we'll have a function called getCurrentWeather() which will return a Discord message. However, in this case we'll change things up a little - and use an embedded message so we can include some formatting. This also means that we'll change our response function to discord.ChannelMessageSendComplex().

Where discord.ChannelMessageSend() needs the channel ID & a simple string message, discord.ChannelMessageSendComplex() will require data of the type discordgo.MessageSend instead of a string. In our getCurrentWeather() function, we'll see how to assemble our response using this structure.

Querying Weather

So to start with, we'll need to query the OpenWeather API & retrieve the current weather information for a given US ZIP code.

In our project, I've created a new Go file called command-weather.go in the same bot subfolder which already contains our bot.go file. This new file will also be part of package bot.

// snippet from: bot/command-weather.go

package bot

import (
	"encoding/json"
	"fmt"
	"io/ioutil"
	"net/http"
	"regexp"
	"strconv"
	"time"

	"github.com/bwmarrin/discordgo"
)

I've included the list of imports above, in case you're following along.

To start with, I'll define two items that we'll need for our `getCurentWeather() function:

// snippet from: bot/command-weather.go

const URL string = "https://api.openweathermap.org/data/2.5/weather?"

type WeatherData struct {
	Weather []struct {
		Description string `json:"description"`
	} `json:"weather"`
	Main struct {
		Temp     float64 `json:"temp"`
		Humidity int     `json:"humidity"`
	} `json:"main"`
	Wind struct {
		Speed float64 `json:"speed"`
	} `json:"wind"`
	Name string `json:"name"`
}

The first item is a constant for the base URL for OpenWeather's API. This should never need to change, which is why we use a constant here.

In addition, I've defined a new struct called WeatherData that will be used to unpack our JSON response from OpenWeather's API. They list a current sample response payload on their documentation page, which we can use to build our data structure. Since we won't be using all of the data provided in the response, I also don't need to define every value in our struct.

Note: Need a quicker way to convert a sample JSON payload to a Golang struct? There are a handful of awesome websites that will do this conversion automatically for you. I like to use this one, but that's only one of a handful of options!

Next, we'll start building out our getCurrentWeather() function:

// snippet from: bot/command-weather.go

func getCurrentWeather(message string) *discordgo.MessageSend {
	// Match 5-digit US ZIP code
	r, _ := regexp.Compile(`\d{5}`)
	zip := r.FindString(message)

	// If ZIP not found, return an error
	if zip == "" {
		return &discordgo.MessageSend{
			Content: "Sorry that ZIP code doesn't look right",
		}
	}

In our function definition, we'll be expecting a string input containing the user's message - for example: "!zip 12345" - and we'll be returning a discordgo.MessageSend object like I mentioned earlier.

Next, we'll do a quick (and very lazy) regular expression match against the incoming message & attempt to pull out the ZIP code. This regex is only searching for a pattern of 5 digits. If the r.FindString() call finds a match, it will return the 5-digit ZIP code. If it doesn't match, it will return an empty string.

In case we don't match a ZIP code, we'll check to see if the zip variable is empty. If it is, we'll have Discord send a message back to the user saying it's invalid.

Here's where we'll see a simple example of using our discordgo.MessageSend object. This object is just a Go struct, so we can use it to store data in the format of Key: Value. In this case, we can still respond with a simple plain-text message - by using the Content key and supplying a string message. The full definition & possible fields for this object are in the discordgo docs.

Next, we'll handle making our HTTP request to the OpenWeather API:

// snippet from: bot/command-weather.go

        weatherURL := fmt.Sprintf("%szip=%s&units=imperial&appid=%s", URL, zip, OpenWeatherToken)

	// Create new HTTP client & set timeout
	client := http.Client{Timeout: 5 * time.Second}

	// Query OpenWeather API
	response, err := client.Get(weatherURL)
	if err != nil {
		return &discordgo.MessageSend{
			Content: "Sorry, there was an error trying to get the weather",
		}
	}

We'll start by generating the complete API URL. We'll use fmt.Sprintf to generate a formatted string & inject variable values. So in this case, we're combining the base URL with the required parameters: zip, units, and appid. The zip value will contain the ZIP code we just collected from our user. appid will be our OpenWeather API key. Using fmt.Sprintf, we can inject variables using the %s placeholder for string values - then provide those values as inputs.

So for example, a full query URL for ZIP code 12345 may look similar to this: https://api.openweathermap.org/data/2.5/weather?zip=12345&units=imperial&appid=ABCDEF12345

Then we create a new instance of an http.Client - just so we can modify the timeout value to 5 seconds. We then use this client to issue a HTTP GET request to the full weatherURL.

Assuming the call succeeds, we'll have our JSON response stored in the response variable. If not, we'll quickly check for an error & send a Discord message back to the user.

Generating a Discord Embed Message

Now we can parse our JSON response:

// snippet from: bot/command-weather.go

	// Open HTTP response body
	body, _ := ioutil.ReadAll(response.Body)
	defer response.Body.Close()

	// Convert JSON
	var data WeatherData
	json.Unmarshal([]byte(body), &data)

	// Pull out desired weather info & Convert to string if necessary
	city := data.Name
	conditions := data.Weather[0].Description
	temperature := strconv.FormatFloat(data.Main.Temp, 'f', 2, 64)
	humidity := strconv.Itoa(data.Main.Humidity)
	wind := strconv.FormatFloat(data.Wind.Speed, 'f', 2, 64)

We'll read in the HTTP response body using ioutil.ReadAll(). We'll need to close this object, so we'll immediately follow that read with a defer response.Body.Close() to make sure that happens.

Next we'll create a new variable called data, which will be of type WeatherData - our struct that we created earlier to store the JSON data. We can then convert that JSON response to our data object using json.Unmarshal() and passing our HTTP body & the target variable to store the data in.

Lastly, we can start pulling out the information we need to use later. For clarity, I've chosen to pull out each individual value here & assign them to their own variable names. Since our Discord response can only be a string, we also handle type conversions here - from integer or float64 to a string.

Finally, we can generate our Discord MessageSend object:

// snippet from: bot/command-weather.go

	// Build Discord embed response
	embed := &discordgo.MessageSend{
		Embeds: []*discordgo.MessageEmbed{{
			Type:        discordgo.EmbedTypeRich,
			Title:       "Current Weather",
			Description: "Weather for " + city,
			Fields: []*discordgo.MessageEmbedField{
				{
					Name:   "Conditions",
					Value:  conditions,
					Inline: true,
				},
				{
					Name:   "Temperature",
					Value:  temperature + "°F",
					Inline: true,
				},
				{
					Name:   "Humidity",
					Value:  humidity + "%",
					Inline: true,
				},
				{
					Name:   "Wind",
					Value:  wind + " mph",
					Inline: true,
				},
			},
		},
		},
	}

	return embed
}

We'll create a new variable called embed, which will store a discordgo.MessageSend struct.

Earlier we used this to send a Content: value, but this time we'll be leveraging the Embeds key. Embeds is of the type []*discordgo.MessageEmbed, which is again just a struct to store data. For reference, the format & possible fields of the MessageEmbed object are listed here

Within this MessageEmbed object, we can start filling in data about our message. So we fill in our message Title and Description - and tell Discord that this is going to be a richtext type of response.

We'll be displaying a box in discord that has a number of key/value pairs - which we'll use to show the measurement title & value. To do this, we'll fill in the Fields: key - which takes in a list of structs of the type []*discordgo.MessageEmbedField.

For each of the weather measurements we want to display, we'll provide a Name:, the Value:, and specify Inline: true. The Inline options tells Discord to try and list each key/value inline with eachother, rather than placing each pair on a new line.

Last, we'll return our discordgo.MessageSend object via return embed.

Testing

Let's test this out. We can stop our currently running bot with Ctrl+C, then restart with go run main.go.

We should be able to ask our bot for the weather using our new !zip command:

basic-bot-command

Look at that! The bot quickly responds with the current weather, and formatted in an embedded message. There's a lot more we could do with embedded messages, but this is just an example to get started.


Okay - I think that wraps up this blog post. I know it was a long one, so if you're still with me - Thank you! I hope this was helpful.

I am planning on a follow up post shortly, where I'll cover how to add Discord slash commands to our example bot. Check back soon or subscribe to the Blog or YouTube if you're interested!