Create a basic Discord bot in F#

I have been using Discord a lot lately, mainly because I needed a space to meet for role-playing games remotely during the Black Plague. One nice perk of Discord is its support for bots. In particular, I used a bot called Groovy, which allowed streaming music from various sources like YouTube during games, and was great to set the tone for epic moments in a campaign. Unfortunately, Groovy wasn’t complying with the YouTube terms of service, and fell to the ban hammer. No more epic music for my epic D&D encounters :(

As the proverb goes, “necessity is the mother of invention”. If there is no bot I can readily use, how difficult could it be to create my own replacement, in F#?

In this post, I will go over the basics of creating a Discord bot in F#, using the DSharpPlus library. Later on, I will follow up with a post focusing on streaming music.

The bot we will write here will be pretty basic: we will add a fancier version of hello world, with a command inspire that we can trigger from Discord:

/inspire @bruenor

.. which will cast Bardic Inspiration on @bruenor, a user on our server, responding with a message

Bardic Inspiration! @bruenor, add 3 (1d6) to your next ability check, attack, or saving throw.

Prerequisites / Setup

For us to use a bot in Discord, we will need 2 things:

To create the app, go to the Discord developers page, where you can create an Application. Once that application is created, go to the Bot section. In there you will find a link to a Token, which will be used to authenticate our code later on.

To add your bot to a server, in the Discord developers page, go to the OAuth2 section, and go to the OAuth2 URL Generator section. In the section labeled Scopes, select bot. You should see a url like this one appear:

https://discord.com/api/oauth2/authorize?client_id=YOUR_APP_CLIENT_ID&permissions=0&scope=bot

Note the argument permissions=0. To give your bot permission to perform some actions, select below the bot permissions you want, in our case, Sent Messages, which will convert the url to

https://discord.com/api/oauth2/authorize?client_id=YOUR_APP_CLIENT_ID&permissions=2048&scope=bot

Once that is done, copy that url in your browser, which will ask you to select the server(s) where you would like this bot to be added.

At that point, we are set: we have all the hooks we need, all that is missing is code for our bot to do something.

Setting up our Bot

Our bot will be a basic console app. Let’s get that wired up. In VS Code, we’ll create that console app:

dotnet new console --language F# --name BardicInspiration

To avoid hard-coding our bot token in code, let’s put it in an AppSettings.json file, adding the nuget packages Microsoft.Extensions.Hosting, Microsoft.Extensions.Configuration and Microsoft.Extensions.Configuration.Json to our project, and making sure that AppSettings.json is copied during build and publish in the BardicInspiration.fsproj file.

Code: Initial console app setup

Now that we have a token, let’s connect to Discord, using DSharpPlus. We’ll add 2 more packages, DSharpPlus and DSharpPlus.CommandsNext, and modify our program to create a Discord client, using our bot token:

[<EntryPoint>]
let main argv =
    printfn "Starting"

    let token = appConfig.["Token"]
    let config = DiscordConfiguration ()
    config.Token <- token
    config.TokenType <- TokenType.Bot

    let client = new DiscordClient(config)

    1

Code: Creating a Discord client

Wiring up our first Command

We are now ready to add a command. Commands in DSharpPlus use the DSharpPlus.CommandsNext package, and must be hosted in a class that inherits from BaseCommandModule. Let’s create a separate file for our bot commands, DiscordBot.fs, and create our bot:

open DSharpPlus.CommandsNext

type BardBot () =

    inherit BaseCommandModule ()

Let’s add our first command. Commands are methods or functions, decorated with attributes. Following along the C# docs for DSharpPlus, translating into F#, I got my first command working:

[<Command>]
let inspiration (ctx: CommandContext) =
    async {
        do!
            ctx.TriggerTypingAsync()
            |> Async.AwaitTask

        let rng = Random ()
        let emoji =
            DiscordEmoji.FromName(ctx.Client, ":game_die:").ToString()

        do!
            rng.Next(1, 7)
            |> sprintf "%s Bardic Inspiration! Add %i to your next ability check, attack, or saving throw." emoji
            |> ctx.RespondAsync
            |> Async.AwaitTask
            |> Async.Ignore
        }
    |> Async.StartAsTask
    :> Task

We’ll revisit that later, to see if we can make things a bit simpler. At a high level, a command is decorated with the [<Command>] attribute, takes in a CommandContext, which provides contextual information (which server or channel is it coming from, which user sent the command…) and possibly arguments, and returns a Task.

Now that our inspiration command is ready, let’s bolt that to our bot, in Program.fs:

let client = new DiscordClient(config)

let commandsConfig = CommandsNextConfiguration ()
commandsConfig.StringPrefixes <- ["/"]

let commands = client.UseCommandsNext(commandsConfig)
commands.RegisterCommands<BardBot>()

client.ConnectAsync()
|> Async.AwaitTask
|> Async.RunSynchronously

Task.Delay(-1)
|> Async.AwaitTask
|> Async.RunSynchronously

We set the prefix of commands to /, so we can invoke them like /inspiration, and register our BardBot with the client - and we start the whole thing up, connecting our client to Discord.

Code: First Command

At that point, if you build and run the program, and added the bot to your server, it should work:

Bardic inspiration in action

Puttings some bells and whistles on that Command

At that point, we have a working Command, which is great. However, we also have some small issues.

First, while using a function works perfectly fine, it will prevent us from using command overloads, if that is something we wanted to support. So I rewrote the command, making a few changes:

[<Command "inspire">]
[<Description "Cast bardic inspiration">]
member this.Inspiration (ctx: CommandContext) =

With that change, we could now support an alternate version, where we can cast Bardic Inspiration on a specific user, like this:

[<Command "inspire">]
[<Description "Cast bardic inspiration on someone!">]
member this.Inspiration (ctx: CommandContext, [<Description "Who do you want to inspire?">] user: DiscordMember) =

I added a few more attributes, which illustrate some interesting points:

… which can then be used in Discord like so:

Command help

Note also in the method signature how user is a DiscordMember. DSharpPlus will use that information to try and parse the command argument directly into a user for us.

From Async to Task

The other small issue is the friction between async and Task. F# 6 includes native support for task, which would be perfect here, but at the time of writing, .NET 6 is still in release candidate, so I decided to use Ply instead for now. After adding the Ply package, we can now rewrite our method like so:

[<Command "inspire">]
[<Description "Cast bardic inspiration on someone!">]
member this.Inspiration (ctx: CommandContext, [<Description "Who do you want to inspire?">] user: DiscordMember) =
    unitTask {
        do!
            ctx.TriggerTypingAsync()

        let emoji = DiscordEmoji.FromName(ctx.Client, ":drum:").Name
        let roll = Random().Next(1, 7)
        let userName = user.Mention

        let! _ =
            sprintf "%s Bardic Inspiration! %s, add %i (1d6) to your next ability check, attack, or saving throw." emoji userName roll
            |> ctx.RespondAsync

        return ()
        }

And we are done! We have a fully functioning Discord Bot, with a command:

Bardic inspiration in action

Code: Final State of Affairs

Conclusion

Well, that’s it for today! If you are interested in writing Discord bots, hope this blog post helps you get started on the right foot. In the next installment, I plan on going over how to add music streaming to that bot. In the meanwhile, ping me on twitter if you have have questions or comments, and… happy coding!

Do you have a comment or a question?
Ping me on Mastodon!