Blog post

Building a Discord bot in Rust

2022-09-14T15:00:00

22 minute read

In this post, we will look at a simple way to add custom functionality to a Discord server using a bot written in Rust. We will first register a bot with Discord, then go about how to create a Serenity application that will later run on shuttle. Finally, we will make the bot do something useful, writing some Rust code to get information from an external service.

The full code can be found in this repository.

Registering our bot

Before we start making our bot, we need to register it for Discord. We do that by going to https://discord.com/developers/applications and creating a new application.

The application process is also used for adding functionality to Discord but we will be only using the bot offering. Fill in the basic details and you should get to the following screen:

You want to copy the Application ID and have it handy, because we will use it to add our bot to a test server.

Next, we want to create a bot. You can set its public username here:

You want to click the reset token and copy this value (we will use it in a later step). This value represents the username and password as a single value that Discord uses to authenticate that our server is the one controlling the bot. You want to keep this value secret.

You also want to tick the MESSAGE CONTENT INTENT setting so it can read the commands input.

To add the bot to the server we will test on, we can use the following URL (replace *application_id* in the URL with the ID you copied beforehand):

1https://discord.com/oauth2/authorize?client_id=*application_id*&scope=bot&permissions=8
2

Here, we create it with permissions=8 so that it can do everything on the server. If you are adding to another server, select only the permissions it needs.

We now have a bot on our server:

Oh, they’re offline 😢

Getting a bot online

At this moment, our bot is not running because there is no code. We will have to write it and run it before we can start interacting with it.

Serenity

Serenity is a library for writing Discord bots (and communicating with the Discord API). We can create a new Serenity project which is readily deployable on shuttle with: cargo shuttle init --serenity

If you don’t have shuttle yet, you can install it with cargo install cargo-shuttle. Afterwards, run the following in an empty directory:

1cargo shuttle init --serenity
2

After running it you, should see the following generated in src/lib.rs:

1use log::{error, info};

2use serenity::async_trait;

3use serenity::model::channel::Message;

4use serenity::model::gateway::Ready;

5use serenity::prelude::*;

6use shuttle_service::error::CustomError;

7use shuttle_service::SecretStore;

8use sqlx::PgPool;

9

10struct Bot;

11

12#[async_trait]

13impl EventHandler for Bot {

14    async fn message(&self, ctx: Context, msg: Message) {

15        if msg.content == "!hello" {

16            if let Err(e) = msg.channel_id.say(&ctx.http, "world!").await {

17                error!("Error sending message: {:?}", e);

18            }

19        }

20    }

21

22    async fn ready(&self, _: Context, ready: Ready) {

23        info!("{} is connected!", ready.user.name);

24    }

25}

26

27#[shuttle_service::main]

28async fn serenity(#[shared::Postgres] pool: PgPool) -> shuttle_service::ShuttleSerenity {

29    // Get the discord token set in `Secrets.toml` from the shared Postgres database

30    let token = pool

31        .get_secret("DISCORD_TOKEN")

32        .await

33        .map_err(CustomError::new)?;

34

35    // Set gateway intents, which decides what events the bot will be notified about

36    let intents = GatewayIntents::GUILD_MESSAGES | GatewayIntents::MESSAGE_CONTENT;

37

38    let client = Client::builder(&token, intents)

39        .event_handler(Bot)

40        .await

41        .expect("Err creating client");

42

43    Ok(client)

44}
45

Building an interaction for our bot

We want to call our bot when chatting in a text channel. Discord enables this with slash commands.

Slash commands can be server-specific (servers are named as guilds in Discords API documentation) or application specific (across all servers the bot is in). For testing, we will only enable it on a single guild/server. This is because the application-wide commands can take an hour to fully register whereas the guild/server specific ones are instant, so we can test the new commands immediately.

You can copy the guild ID by right-clicking here on the server name and click copy ID (you will need developer mode enabled to do this):

Now that we have the information for setup, we can start writing our bot and its commands.

We will first get rid of the async fn message hook as we won’t be using it in this example. In the ready hook we will call set_application_commands with a GuildId to register a command with Discord. Here we register a hello command with a description and no parameters (Discord refers to these as options).

1#[async_trait]

2impl EventHandler for Bot {

3    async fn ready(&self, ctx: Context, ready: Ready) {

4        info!("{} is connected!", ready.user.name);

5

6        let guild_id = GuildId(*your guild id*);

7

8        let commands = GuildId::set_application_commands(&guild_id, &ctx.http, |commands| {

9            commands.create_application_command(|command| { command.name("hello").description("Say hello") })

10        }).await.unwrap();

11

12        info!("{:#?}", commands);

13    }

14}
15

Serenity has a bit of a different way of registering commands using a callback. If you are working on a larger command application, poise (which builds on Serenity) might be better suited.

With our command registered, we will now add a hook for when these commands are called using interaction_create.

1#[async_trait]

2impl EventHandler for Bot {

3    async fn ready(&self, ctx: Context, ready: Ready) {

4        // ...

5    }

6

7    async fn interaction_create(&self, ctx: Context, interaction: Interaction) {

8        if let Interaction::ApplicationCommand(command) = interaction {

9            let response_content = match command.data.name.as_str() {

10                "hello" => "hello".to_owned(),

11                command => unreachable!("Unknown command: {}", command),

12            };

13

14            let create_interaction_response =

15                command.create_interaction_response(&ctx.http, |response| {

16                    response

17                        .kind(InteractionResponseType::ChannelMessageWithSource)

18                        .interaction_response_data(|message| message.content(response_content))

19                });

20

21            if let Err(why) = create_interaction_response.await {

22                eprintln!("Cannot respond to slash command: {}", why);

23            }

24        }

25    }

26}
27

Trying it out

Now with the code written we can test it locally. Before we do that we have to authenticate the bot with Discord. We do this with the value we got from "Reset Token" on the bot screen in one of the previous steps. To register a secret with shuttle we create a Secrets.toml file with a key value pair. This pair is read by the pool.get_secret("DISCORD_TOKEN") call in the ready hook:

1# Secrets.toml

2DISCORD_TOKEN="*your discord token*"

3DISCORD_GUILD_ID="*the guild we are testing on*"
4

Currently secrets are stored using shuttle's database Postgres offering (thus why the parameter on main is PgPool). Therefore during local testing you need access to a Postgres database. Shuttle's local runner does this using Docker which will require Docker desktop being locally installed to use the Secrets locally. This is is subject to change in the future so that it doesn't require Docker for local secrets. There is also a known issue with deploying Secrets on Windows so if you have problems consult the Discord.

cargo shuttle run

We should see that our bot now displays as online:

When typing, we should see our command come up with its description:

Our bot should respond with "hello" to our command:

Wow! Let’s make our bot do something a little more useful.

Making the bot do something

There is plenty of free APIs that can be used for getting information on a variety of topics.

For this demo, we are going to build a bot that gives a forecast for a location. I used the AccuWeather API for this demo. If you are following this tutorial 1:1 you can go and register an application to get an access key. If you are using a different API this is still the sort of process you would follow.

To get a forecast using the API requires two requests:

  1. Get a location ID for a named location
  2. Get the forecast at the location ID

The API requires making network requests and it returns a JSON response. We can make the requests with cargo add reqwest -F json and deserialize the results to structures using serde, with cargo add serde. We will then have a function that chains the two requests together and deserializes the forecast to a readable result.

You can skip some of the boilerplate by using direct access on untyped values. But we will opt for the better strongly typed structured approach.

Here we type some of the structures returned by the API and add #[derive(Deserialize)] so they can be decoded from JSON. All the keys are in PascalCase so we use the #[serde(rename_all = "PascalCase")] helper attribute to stay aligned with Rust standards. Some are completely different from the Rust field name so we use #[serde(alias = ...)] on the field to set its matching JSON representation.

1// In weather.rs

2use serde::Deserialize;

3

4#[derive(Deserialize, Debug)]

5#[serde(rename_all = "PascalCase")]

6pub struct Location {

7    key: String,

8    localized_name: String,

9    country: Country,

10}

11

12impl Display for Location {

13    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {

14        write!(f, "{}, {}", self.localized_name, self.country.id)

15    }

16}

17

18#[derive(Deserialize, Debug)]

19pub struct Country {

20    #[serde(alias = "ID")]

21    pub id: String,

22}

23

24#[derive(Deserialize, Debug)]

25#[serde(rename_all = "PascalCase")]

26pub struct Forecast {

27    pub headline: Headline,

28}

29

30#[derive(Deserialize, Debug)]

31pub struct Headline {

32    #[serde(alias = "Text")]

33    pub overview: String,

34}
35

The above skips a lot of the fields returned by the API, only opting for the ones we will use in this demo. If you wanted to type all the fields you could try the new type from JSON feature in rust-analyzer to avoid having to write as much.

Our location request call also fails if the search we put in returns no places. We will create an intermediate type that represents this case and implements std::error::Error:

1// Again in weather.rs

2use std::fmt::Display;

3

4#[derive(Debug)]

5pub struct CouldNotFindLocation {

6    place: String,

7}

8

9impl Display for CouldNotFindLocation {

10    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {

11        write!(f, "Could not find location '{}'", self.place)

12    }

13}

14

15impl std::error::Error for CouldNotFindLocation {}
16

Now with all the types written, we create a new async function that, given a place and a client, will return the forecast along with the location:

1// Again in weather.rs

2pub async fn get_forecast(

3    place: &str,

4		api_key: &str,

5    client: &Client,

6) -> Result<(Location, Forecast), Box<dyn std::error::Error>> {

7		// Endpoints we will use

8    const LOCATION_REQUEST: &str = "http://dataservice.accuweather.com/locations/v1/cities/search";

9    const DAY_REQUEST: &str = "http://dataservice.accuweather.com/forecasts/v1/daily/1day/";

10

11		// The URL to call combined with our API_KEY and the place (via the q search parameter)

12    let url = format!("{}?apikey={}&q={}", LOCATION_REQUEST, api_key, place);

13		// Make the request we will call

14    let request = client.get(url).build().unwrap();

15		// Execute the request and await a JSON result that will be converted to a

16		// vector of locations

17    let resp = client

18        .execute(request)

19        .await?

20        .json::<Vec<Location>>()

21        .await?;

22

23		// Get the first location. If empty respond with the above declared

24		// `CouldNotFindLocation` error type

25    let first_location = resp

26        .into_iter()

27        .next()

28        .ok_or_else(|| CouldNotFindLocation {

29            place: place.to_owned(),

30        })?;

31

32		// Now have the location combine the key/identifier with the URL 

33    let url = format!("{}{}?apikey={}", DAY_REQUEST, first_location.key, api_key);

34

35		let request = client.get(url).build().unwrap();

36    let forecast = client

37        .execute(request)

38        .await?

39        .json::<Forecast>()

40        .await?;

41

42		// Combine the location with the foreact

43    Ok((first_location, forecast))

44}
45

Now we have a function to get the weather, given a reqwest client and a place, we can wire that into the bots logic.

Setting up the reqwest client

Our get_forecast requires a reqwest Client and the weather API key. We will add some fields to our bot for holding this data and initialize this in the shuttle_service::main function. Using the secrets feature we can get our weather API key:

1// In lib.rs

2struct Bot {

3    weather_api_key: String,

4    client: reqwest::Client,

5		discord_guild_id: GuildId,

6}

7

8#[shuttle_service::main]

9async fn serenity(#[shared::Postgres] pool: PgPool) -> shuttle_service::ShuttleSerenity {

10    // Get the discord token set in `Secrets.toml` from the shared Postgres database

11    let token = pool

12        .get_secret("DISCORD_TOKEN")

13        .await

14        .map_err(CustomError::new)?;

15

16    let weather_api_key = pool

17        .get_secret("WEATHER_API_KEY")

18        .await

19        .map_err(CustomError::new)?;

20

21		let discord_guild_id = pool

22        .get_secret("DISCORD_GUILD_ID")

23        .await

24        .map_err(CustomError::new)?;

25

26    // Set gateway intents, which decides what events the bot will be notified about

27    let intents = GatewayIntents::GUILD_MESSAGES | GatewayIntents::MESSAGE_CONTENT;

28

29    let client = Client::builder(&token, intents)

30        .event_handler(Bot {

31            weather_api_key,

32            client: reqwest::Client::new(),

33						discord_guild_id: GuildId(discord_guild_id.parse().unwrap())

34        })

35        .await

36        .expect("Err creating client");

37

38    Ok(client)

39}
40

Registering a /weather command

We will add our new command with a place option/parameter. Back in the ready hook, we can add an additional command alongside the existing hello command:

1let commands = GuildId::set_application_commands(&guild_id, &ctx.http, |commands| {

2    commands

3        .create_application_command(|command| { command.name("hello").description("Say hello") })

4        .create_application_command(|command| {

5            command

6                .name("weather")

7                .description("Display the weather")

8                .create_option(|option| {

9                    option

10                        .name("place")

11                        .description("City to lookup forecast")

12                        .kind(CommandOptionType::String)

13                        .required(true)

14                })

15        })

16}).await.unwrap();
17

Discord allows us to set the expected type and whether it is required. Here, the place needs to be a string and is required.

Now in the interaction handler, we can add a new branch to the match tree. We pull out the option/argument corresponding to place and extract its value. Because of the restrictions made when setting the option we can assume that it is well-formed (unless Discord sends a bad request) and thus the unwraps here. After we have the arguments of the command we call the get_forecast function and format the results into a string to return.

1"weather" => {

2    let argument = command

3        .data

4        .options

5        .iter()

6        .find(|opt| opt.name == "place")

7        .cloned();

8

9    let value = argument.unwrap().value.unwrap();

10    let place = value.as_str().unwrap();

11    let result = weather::get_forecast(place).await;		

12

13    match result {

14        Ok((location, forecast)) => format!(

15            "Forecast: {} in {}",

16            forecast.headline.overview, location

17        ),

18        Err(err) => {

19            format!("Err: {}", err)

20        }

21    }

22}
23

Running

Now, we have these additional secrets we are using and we will add them to the Secrets.toml file:

1# In Secrets.toml

2# Existing secrets:

3DISCORD_TOKEN="***"

4DISCORD_GUILD_ID="***"

5# New secret

6WEATHER_API_KEY="***"
7

With the secrets added, we can run the server:

cargo shuttle run

While typing, we should see our command come up with the options/parameters:

Entering “Paris” as the place we get a result with a forecast:

And entering a location that isn’t registered returns an error, thanks to the error handling we added to the get_forecast function:

Deploying on shuttle

With all of that setup, it is really easy to get your bot hosted and running without having to run your PC 24/7.

Just write:

cargo shuttle deploy

And you are good to go. Easy-pease, right?

You could now take this idea even further:

  • Use a different API, to create a bot that can return funny facts or return new spaceflights
  • Maybe you could use one of shuttle's provided databases to remember certain information about a user
  • Expand on the weather forecast idea by adding more advanced options and follow-ups to command options
  • Use the localization information to return information in other languages

This blog post is powered by shuttle! If you have any questions, or want to provide feedback, join our Discord server!

Shuttle: The Rust-native, open source, cloud development platform.

Deploying and managing your Rust web apps can be an expensive, anxious and time consuming process.

If you want a batteries included and ops-free experience, try out Shuttle.


Share this article

Let's make Rust the next language of cloud-native

We love you Go, but Rust is just better.