The Adventures Of MoeBot
It is somewhat a rite of passage for developers who use chat clients to create their own bot. After my successful endeavour with HumphBot1 I decided to make an automaton of my own which would eventually become MoeBot. While I did make a working bot with Node.js the experience was not very enjoyable so instead I took this opportunity to go even further out and get more F# practice. While there exists a .NET library for making Discord bots I made the decision to do the entire thing from scratch as interacting with C# focused libraries in F# kind of sucks. This decision was bolstered by my prior experience with Discord’s API documentation which, especially in comparison with node’s, I consider to be very good. With all the important decisions made it was time to get moe.
As you may have guessed from that introduction this post contains a lot, I mean a LOT, of programming and code but I’ve done my best to explain it all. Don’t give up already if you’re not a programmer! Join us on this adventure and get a peek behind the curtain of all the bots you use without a moment’s hesitation. At the end there will be freshly baked cookies for all.
Discord bots2 are, like everything these days, just websites with a specific API. When you ask a bot to do something like find a funny picture to post by typing /picture
the Discord client turns that into a request to the bot’s webserver. Building website APIs is my day job so getting my bot up and running should be no trouble at all, right? As bots can be given permission to perform very destructive or significant actions they need to be protected by some form of authentication. No one wants to wake up one day and find their community gone3 because of some rogue actor! To keep things secure Discord gives every bot a private key and requires them to verify the incoming request against it. Before your bot is allowed onto the platform it must pass a number of challenge tests that check both valid requests are processed and invalid requests are rejected.
Ping and Pong
Responding to the challenge correctly happens in two stages: verify the signature of the request then send back the expected JSON. In order to verify the signature we need to know the fancy maths behind it called the signing algorithm. The one used by Discord is uniquely named “Ed25519”. While .NET provides a lot of cryptographic functions out of the box these are limited to those provided natively by the host OS - Windows, Mac OS and Linux. As it turns out Windows and Linux both provide this function but as usual it’s Apple letting us down with no Mac OS support. I’m not a fan of rolling my own crypto4 so I looked around for an existing implementation to re-use. Unsurprisingly the previously mentioned .NET Discord library has an implementation and thanks to it being MIT licensed we’re able to use it with no issues. I did make an attempt at re-writing this in F# but the low level bit twiddling was a bit out of my depth and as making mistakes here would sink the entire project I quickly gave up.
A Sub Heading? Oh No…
Finally we’re ready to write some code and get this bot on the road! I picked (Falco)https://www.falcoframework.com to be the server instead of using the more C# focused “Kestrel” server directly. This gives us all the power of ASP.NET but with a nice F# way to access it. Discord sends all of its requests to an interactions
address by default so we start by creating a basic function to reply back.
let hello : HttpHandler = fun ctx -> task {
return Response.ofPlainText “Hello world!”
}
Wow that doesn’t look anything like the C# version but we’ll come back and explain what everything does later. For now we need to tell the server to use this function when a request comes in. To achieve that we’re going to define a route and bind this function to it.
webHost args {
endpoints [
get “/interactions” hello
]
}
Now if we run the project, the rest of which is omitted for clarity, we can send a request and receive our hello back. This is great but aren’t we supposed to be implementing some security? While we could add the verification to the hello handler function the typical way is to create “middleware” to perform it automatically. It’s called middleware because it’s software that sits in the middle of the host (Kestrel) and our handler (hello). As with everything in F# we create this by writing a function!
let verifyDiscordMiddleware clientPublicKey (app: IApplicationBuilder) =
let middleware (ctx: HttpContext) (next:RequestDelegate) : Task = task {
match ctx.Request.Path.StartsWithSegments "/interactions" with
| true ->
let! verified = verifyRequest clientPublicKey ctx
match verified with
| true -> return! next.Invoke(ctx)
| false -> return Response.withStatusCode 401 ctx |> ignore
| false -> return! next.Invoke(ctx)
}
app.Use(middleware)
This might start to look a bit more familiar to C# developers but probably still seems like incomprehensible magic to everyone else. There are three main steps being taken here: create a middleware
function that does something with the incoming request; verify the request somehow inside it and finally; give this function to the app to call on all incoming requests then return the builder. Steps one and three are interacting with Kestrel which you can learn all about over on (Microsoft Learn)https://learn.microsoft.com/en-us/aspnet/core/fundamentals/middleware/?view=aspnetcore-8.0, which broke my blog formatter5. Step two is where we stick our code into the mix and decide what happens to the incoming request.
We start by looking to see if the request looks something like “https://moebot.moe/interactions” to know if we should even try verifying the request. As the server might (will) want to do other things this makes sure we’re asking the right questions to the right people. If we’re satisfied this is a request that should be verified we dutifully perform the verification and based on the result decide to either continue processing the request or fold our arms and say “your name’s not down you’re not coming in”6 or the http equivalent - a 401.
let verifyRequest clientPublicKey (ctx: HttpContext) = task {
try
let! body = readyBodyAsString ctx
let signature = getHeaderOption ctx.Request.Headers "X-Signature-Ed25519"
let timestamp = getHeaderOption ctx.Request.Headers "X-Signature-Timestamp"
let message = timestamp + body
let pubKeyHex = Ed25519.HexToByteArray clientPublicKey
let verified = Ed25519.Verify (Ed25519.HexToByteArray signature, Encoding.UTF8.GetBytes(message), pubKeyHex)
return verified
with
| x -> return false
}
Finally the reason we started this whole process! If for some reason you got here looking for code on how to do this verification yourself this is it. The request should contain a timestamp header which when combined with the body matches the signature provided by our bot’s public key as supplied by Discord. Phew what an ordeal, as all things in security tend to be, but we’re finally ready to plug our verification middleware in and move on to part two. Adding it in to the builder is just one line. You will of course need to supply your own public key to get this to work!
webHost args {
use_middleware (verifyDiscordMiddleware clientPublicKey)
endpoints [
get “/interactions” hello
]
}
Security Is Hard Lets Play Table Tennis
Now we know if a request is legit or not we can get around to responding to them with something useful. Almost useful perhaps as we’re just going to respond to a PING request with a PONG to complete our setup. To do this we need to represent the incoming Interaction
object from Discord which we can learn about from their (API documentation)https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object. The bare minimum needed is to work out what type
the interaction is, hopefully a PING, so we can PONG correctly. F# has a great way of modelling this list of things with values called a Discriminated Union (DU).
type InteractionType =
| Ping = 1
| Application_Command = 2
| Message_Component = 3
| Application_Command_Autocomplete = 4
| Modal_Submit = 5
This weird looking structure defines both what things are called and what value they can have. Unlike ways of representing name and value pairs in other language this definition is every possible value. The compiler can then poke us lazy developers if we forget a case or two - something we’ll see shortly. Just the DU on its own isn’t quite enough so we’ll also make another type called a Record7 to model that.
type InteractionObject =
{ Id: string
Type: InteractionType
Data: InteractionData option
User: User option
Member: Member option }
There’s a few more things here that don’t matter for now but I didn’t want to delete them just for this example. The important ones are the Id
claiming to be a string, it’s really a snowflake but that isn’t important right now, and Type
which says it’s our shiny new DU. Everything else is an option
type to say it may or may not be present. We have a matching record and DU to describe what we’re going to reply to Discord with.
type InteractionCallback =
| Pong = 1
| Channel_Message_With_Source = 4
type InteractionResponse =
{ Type: InteractionCallback
Data: InteractionCallbackData Option }
With our data now fully (barely) modelled we can get around to teaching our webserver Falco to understand the request and provide a response. This is done using v4 of Falco as that was the latest version when I started but now there’s a v5 and I don’t want to deal with upgrading from something that still works! Unfortunately that means we’re stuck with a fairly awkward way of receiving and sending JSON. First we’ll look at how we do this seralization and then finally provide the PONG we’ve been seeking all this time.
let post database clients : HttpHandler = fun ctx -> task {
let next interaction ctx : Task = task {
let! response = matchInteraction clients database interaction
return Response.ofJsonOptions jsonOptions response ctx
}
return Request.mapJsonOption jsonOptions next ctx
}
This pile of tasks runs outside in to first turn the incoming request into our InteractionObject
then runs the next
function with that data to create a response. We have to provide some jsonOptions
to tell the seralizer what settings to use; due to decisions made almost 20 years ago the defaults don’t line up with what the rest of the world thinks. The other values database
and client
, which swap position because I’m a bad programmer, don’t matter for now but they’ll be used to do talk to their namesakes later on. We’ve also got a mysterious ctx
, short for context
, which we’re throwing about the place that holds all the information about the incoming and outgoing requests. Although we don’t interact with it directly the Request and Response methods do. After all this hard work it’s time to play table tennis!
let matchInteraction clients database (interaction:InteractionObject) = task {
match interaction.Type with
| InteractionType.Ping ->
let pingTask = Task.FromResult { Type = InteractionCallback.Pong; Data = None }
return! pingTask
| _ -> return! Task.FromException<InteractionResponse> (failwith "unknown/unsupported request type")
}
Finally we can enjoy the benefit of using Discriminated Unions by using the match
expression. We only care about the Ping
value so we can reply to that but to satisfy the compiler we must still explain what happens if another value is supplied. We’ve got a special _
value that matches anything that hasn’t yet been matched and if that happens we’re going to complain loudly. Fingers crossed this won’t ever be matched but one of the key features of F# is making us lazy developers handle every possibility so we won’t be surprised later on. When things have gone right we’re going to create a InteractionResponse
object with just a Pong
and no data then wrap it up in a Task. Using tasks for such a simple response is excessive but in the not too distant future when we do something more useful we’ll need to do it this way.
Phew that was a hell of a lot of work! I’m skipping over how to get this code running on a web server somewhere as it’d be almost as long again. In my case I’m using Azure since I have a lot of experience with it but there’s a million other just as valid hosts out there. Take a breather and relax; in the next section we’re going to add some useful features.
RSS is Cool
Despite multiple attempts by various censorship machines whoops I mean advertising giants to kill it RSS or Really Simple Syndication is a standard format for publishing. Websites can create an RSS feed by providing an XML file and filling it with whatever content they think you might want to read. Anyone who wants to read multiple blogs at once can then add all of these RSS feeds to their reader and see all the updates in one place. Sounds pretty great right? Anything that can make network requests can be an reader so we’re going to make MoeBot do exactly that8!
The checklist of things we need to do is thankfully quite small:
unsupported span found Emphasis ([Literal (" Make network requests ", Some { StartLine = 140 StartColumn = 65 EndLine = 144 EndColumn = 88 })], Some { StartLine = 140 StartColumn = 65 EndLine = 144 EndColumn = 15 })
Understand the response as an RSS feedunsupported span found Emphasis ([Literal (" Send new posts to a channel ", Some { StartLine = 140 StartColumn = 105 EndLine = 144 EndColumn = 134 })], Some { StartLine = 140 StartColumn = 105 EndLine = 144 EndColumn = 15 })
Add new feedsFeed The Bot
Making network requests and fetching the data is possible using the built in HttpClient
and takes a handful of lines to do all of the work for us. We’ll skip over how we get the URL of a feed for now and magically make it appear as a parameter. Turning this into code is surprisingly easy for programmers and probably as complex as all code should be to non-programmers.
let client = new HttpClient()
let! content = client.GetStreamAsync source
Arguments over creating a new client vs re-using the same one over and over have been going on for as long as I’ve been using the venerable HttpClient
. If this bot ever gets popular enough to where this is an issue either way I’ll be very happy to fix any problems that might occur. For now we can take our content stream and try to understand it as RSS.
We know every RSS feed is an XML file so we’re going to take our data and stuff it into an XmlReader
; which does exactly what its name suggests. Unlike HttpClient
we’re going to need to do a little bit of configuration before attempting to read the content otherwise it’ll cross its arms and huff about formatting. We’re floating on the surface of the vast abyss that is the dreaded enterprise programming world here and getting out with just a few configuration bumps is a small price to pay.
let settings = XmlReaderSettings()
settings.Async <- true
settings.IgnoreWhitespace <- true
use reader = XmlReader.Create (content, settings)
Unfortunately this is the end of the easy part. We’re now going to work our way through this XML file first to work out what RSS version it is then to pick out the the juicy stories. There’s only one place to start according to XmlReader
and that is the beginning of the stream so let’s begin! Every part of the stream is represented by a Node9, even the blank bits, so we’re going to doing a lot of checking what the node we’re currently on looks like. Before we can do any of that we need to advance the reader onto the first node. For reasons that I’m sure make sense but are being left unknown the reader begins in some ethereal position before it.
reader.Read() |> ignore
We’re going to become best friends with reader.Read()
soon but for now we are just going to assume the stream has at least one node and advance to it. Here’s where F# does something quite unusual: it forces us to acknowledge that we’re discarding information. Our reader friend does want to tell us if the read was successful or not by returning a bool
with that information in. As we know we’re right at the start we don’t care about this data at all and so don’t even want to bind it to a value. To excuse our frankly rude behaviour we pass this value to a built in function called ignore
which takes any input value and turns it into the a special no-value called unit. Being forced to be explicit about every piece of data is one of the features of F# that make it different to non-functional languages. While it might seem onerous at times the goal of all programs is turning some data into other data so being clear about where you throw things away is very important.
Phew that was a lot of words for such a small line of code! Now that we’re happy the reader is sat on a node and ready to tell us something about it we can start working out what that node is.
match reader.NodeType with
| XmlNodeType.Element -> readElement reader reader.Name
| XmlNodeType.XmlDeclaration -> readNodes reader readElement
| XmlNodeType.Whitespace -> readNodes reader readElement
| XmlNodeType.Comment -> readNodes reader readElement
| XmlNodeType.ProcessingInstruction -> readNodes reader readElement
| _ -> failwith "unable to parse document"
Now there’s a lot of code to look at but upon closer inspection most of it does the same thing. What does match
do and why are we using it here? For the programmers you can view it as a more powerful switch statement. To everyone else it’s a way of looking at a given value, here we’re looking at reader.NodeType
, comparing it to a list of conditions then doing the action assigned to the matching condition. At the bottom there’s the special _
condition again, to remind you it goes “anything not already matched”. If we manage to get to this condition we’re looking at some node type we’re not prepared to understand so we throw our hands in the air, stop caring and blow up in maximum style using F#’s exception generating failwith
and a helpfulish message.
At the start of any XML file comes a special node called the Declaration which, drum-roll please, declares what the rest of the file looks like. If we were doing real enterprise things we’d almost certainly want to look into this declaration and adjust our behaviour accordingly. We’re just going to assume it doesn’t say anything important and call readNodes
10. Likewise if the current node is Whitespace, a Comment or some fancy Processing Instruction. This leaves us with the most useful node - an Element
for us to continue reading and understanding.
let readElement (reader: XmlReader) _ =
let element = reader.Name
let version = reader.GetAttribute("version")
match (element, version) with
| ("rss", "0.91") -> Type.RSS091
| ("rss", "0.92") -> Type.RSS092
| ("rss", "2.0" )-> Type.RSS200
| ("rss", _) -> failwith "unknown rss version"
| ("feed", _) -> Type.Atom
| _ -> failwith "unknown feed type"
Armed with the previous coverage this entire function is looking a little less scary. You might spot the _
in the function declaration up at the top which feels a little confusing: why are we discarding an input value? This is due to some functional programming shenanigans that occur deep inside the main reader code which I won’t be covering. In short we need to match the signature of another version of this function and this is the easiest way to achieve that. What the incoming XML looks like is at least a whole other blog post’s worth of code so this is as far down as we’ll go here. We can see the format of the RSS feed is being discovered here and that will drive the final block of code to produce the full feed.
let feed =
match version with
| Type.RSS091 ->
let rss = readAs091 parseTree
RSS091(rss)
| Type.RSS092 ->
let rss = readAs092 parseTree
RSS092(rss)
| Type.RSS200 ->
let rss = readAs200 source parseTree
RSS200(rss)
| Type.Atom ->
let atom = readAsAtom source parseTree
Atom (atom)
So long as you’re happy to trust me that these readAsX
functions do what they sound like we’re ready to take whatever feed we’ve got and turn it into a channel post. Another feed type called Atom has also snuck in here which isn’t quite RSS but does the same thing in almost the same way.
Posting On Our Own
Unlike the interaction back and forth we started with sending a message is done entirely from our side. Surprisingly this looks quite a lot like how we fetched the feed in the first place. The big secret in creating programs is to make everything as similar as possible so you can use the same pieces of code over and over again.
To make a post we need 3 pieces of information:
unsupported span found Emphasis ([Literal (" Authorization Token ", Some { StartLine = 224 StartColumn = 48 EndLine = 227 EndColumn = 69 })], Some { StartLine = 224 StartColumn = 48 EndLine = 227 EndColumn = 18 })
Channel Id * Message ContentsUnlike the signature validation dance we performed before the authorization token doesn’t require any calculations. This is a long string of characters that Discord generates for us when we registered our bot and entrusts to us to keep safe. Computers are surprisingly terrible at keeping secrets so there’s yet another blog post’s worth of details to be skipped here. Once we do have the token there’s a neat way to add it at the start to another HttpClient
to always include it.
let getClient discordToken baseAddress =
let client = new HttpClient()
client.BaseAddress <- new System.Uri(baseAddress)
client.DefaultRequestHeaders.Add ("Authorization", $"Bot {discordToken}")
client
Whenever this client is used it’ll always include the token in the way Discord expects it. One down two to go!
let getDiscordChannelClient discordToken channelId =
getClient discordToken $"https://discord.com/api/v10/channels/{channelId}/"
Like the token the channel Id is provided by Discord but this time it’s public data so we don’t have to worry about storing it securely. To find the Id of any channel you can enable developer mode in your very own discord client11 and find it in the right click menu from the channel name. We’ll make a function that wraps the previous one and asks for the channel Id separately so we can use this with any channel we like.
Finally we can decide what to put into the message and with a bit of co-ordination to bring all of the previous steps together display a feed. RSS feeds don’t require the provider to include every piece of data so we might not be given a title or even the URL. Discord automatically provides previews of URLs which lets us be lazy and not have to generate more detailed previews.
let sendNewItems (item:ItemCommon) =
let message =
match (item.Link, item.Title) with
| (Some link, _) -> link.ToString()
| (_, Some title) -> title
| _ -> $"{item.Guid} can't be posted"
let client = channelClient Data.firehoseChannelId
let sendMessageContent = Channels.sendMessage client message
sendMessageContent
This looks pretty similar to the way we understood the RSS type a few screens further up this post. Here we’re using the duality of the Option
type in combination with the discard to perform a three way match. Once we’ve decided what to send we finally provide the channel Id to the previous functions allowing them to generate the fully configured HttpClient
and we can combine everything to send the message.
With this done we’re ready to hook it all up and start being informed of new posts right from the comfort of our discord server. There’s just one catch - we haven’t any feeds to fetch!
By My Command
We could include a predefined list of feeds in the code the same way we do with the channel Id from before. If we wanted to add a new feed that would require updating the code and putting out a new version. This isn’t the worst choice in the world but I want a command that I can send from discord. Creating a command has two parts12: registering a command with Discord then, responding to a user executing it.
Discord’s command API13] is one of the more impressive features of the platform supporting far more than just a simple message. There’s three ways to generate a command: the traditional /command
, a button underneath a message and an entry in the context menu. We’ll be diving into the slash commands to manage feeds and still barely scratch the surface of what they’re capable of.
/register Command
If you type / into your client you’ll see a bunch of possible commands turn up. If we want our command to show up here then we’re going to have to register it. Thankfully the team over at Discord know developers like communicating in code and only code so we manage everything to do with commands via a REST interface just like we’ve done with all of the interactions. To create a command all we have to do is send a POST to the application’s command endpoint with the required information.
What’s the required information - well a name I suppose. There is a little more to it than that but not by a lot. We’ll need to classify our commands as either guild specific, a guild is the internal name for what are branded as servers, or a general command. To register the most basic chat command we set up a HttpClient
just like before with channels only this time point it at our application specific endpoint:
let getDiscordBotClient appId discordToken =
getClient discordToken $"https://discord.com/api/v10/applications/{appId}/"
Isn’t the payoff for when you finally get to start re-using code written for some other part of the project the best feeling in programming? I’ll skip over the rest of the code as it’s almost identical to the rest of the channel behaviour and jump into representing the data we need to send. Again we’re going to use Discriminated Unions to handle the different number values for the various types available.
type ApplicationCommandType =
| Chat_Input = 1
| User = 2
| Message = 3
type ApplicationCommandOptionType =
| Sub_Command = 1
/* lots more here */
| Attachment = 11
type ApplicationCommandOption = {
Type : ApplicationCommandOptionType
Name : string
Description : string
Required : bool option
}
type ApplicationCommand = {
Name: string
Type: ApplicationCommandType
[<JsonIgnore>]
Target: ApplicationCommandTarget
Description: string
Version: string option
Options: ApplicationCommandOption list option
}
That’s a whole bunch of types in one go! One of the unusual features of F# is a strict requirement that everything must be defined before being referenced. When describing some complex types like the ApplicationCommand
you’ll usually find the most interesting one at the bottom of the file and you can work backwards to the top to find out how everything inside is defined. This does mean if you ever get lost in F# and can’t find something you know it must be defined before the place you’re seeing it be used in. There’s also something else new sneaking in here: [<JsonIgnore>]
. This is an attribute definition to provide a bit of extra information for the Target
property and in this case tells the JSON seralizer to skip over this property whenever we feed it one of this type.
Can you work out what Options
is all about? A basic command might just be a single /action
and be happy just knowing the user wanted it done but a complex command, like fetching an RSS feed, needs to know a bit more. That’s where the options part of the command comes in. We developers can tell discord we need more data to run our command and the specific type of data for each part. This is then used by the client to let the user fill in the command with helpful hints and error messages. To keep things simple all we’re going to ask for is a single string, that’s type 3, and handle checking if it’s a valid URL ourselves. Let’s see how to build this command.
{ Name = "feed"
Type = ApplicationCommandType.Chat_Input
Target = Guild
Description = "fetch a feed"
Version = None
Options = Some [{
Type = ApplicationCommandOptionType.String
Name = "feed_url"
Description = "enter the full url of the feed to fetch"
Required = Some true }] }
Surprisingly easy to read even if you’ve never written a single line of code! Commands only need to be registered once so I’ll handle that and we can move on to the other half of commands - receiving them.
/feed say.itaint.moe/rss.xml
We receive commands via interaction requests just like we did with the Ping command right at the start. If you can cast your mind, or scroll it’s probably easier, all the way back to the start we set up a matchInteraction
function that had a Ping
interaction type branch. Now we’re ready to add in the Application_Command
branch and do something cool with the request.
| InteractionType.Application_Command when interaction.Data.IsSome ->
let cmd = BotCommand.parse interaction.Data.Value.Name
return! executeCommand clients database cmd interaction
Although there’s a level of security around receiving interactions we still need to check the interaction that it has data for us to work with. We can do this as part of the match expression with the when
and the corresponding check; here just a simple yes or no check but you can put anything that turns into a condition here giving us a lot of control on what path to take.
Commands are registered with just a string for a name so first we’re going to turn that into a type with a custom parse command attached to the DU.
type BotCommand =
| GetFeed
| Unknown
static member parse = function
| "feed" -> GetFeed
| _ -> Unknown
It’s a good idea to represent the unknown as a specific type so later on we can handle it directly and not fall flat on our face. We’re also using a special function
version of the match
expression we’ve used a few times that looks at the data being matched on and automatically works out the incoming parameter. Avoiding duplicate definitions is something Functional Programmers seem to enjoy but it’s one of the bigger hills to conquer on the path to becoming one. The interior of executeCommand
is surprisingly simple as we fan out against the command type that was just worked out.
let executeCommand (clients:Data.Clients) (database:Database) (command:BotCommand) (interaction:InteractionObject) = task {
return!
match command with
| GetFeed -> getFeed interaction.Data.Value
| Unknown -> unknown interaction.Data.Value
}
I won’t cover the rest of the code as it’s very similar to what we’ve already looked at just in a different order and this post is already far too long. In short the incoming value is parsed as a URI; fed into the RSS library to fetch the feed; turned into a list of stories that’s sorted by date published then finally packaged up into a string response and sent back to Discord!
That’s A Wrap
For the adventurers who made it all the way down here: wow thank you! Hopefully you’ve learned a little about F#, Discord and general development. You can of course checkout MoeBot in action over on my Discord. Learning how Falco works and using F# to model a reasonably complex domain like RSS and the Discord API was pretty enjoyable and taught me a lot about how to use the F# type system. There were a lot of struggles with getting Falco to work with async and Task based stuff. Once I understood the error messages I was able to work all of these out although there are still a few bugs here and there.
Debugging issues remains quite a challenge as Discord does not provide any sort of feedback when an interaction request fails. Frequently the error message is “MoeBot didn’t respond in time” but this usually means the response is in a format Discord doesn’t like and the message changes to “The application did not respond” if you view another channel! The documentation does not give any hints nor is there a way to send a debug command to your local machine and somewhat surprisingly there is no official discord bot developer server!
In the future I hope to add more functionality to MoeBot and check out how Discord allows entire web pages to be embedded as part of their Interactions API. I was already able to put to use my improved async knowledge to work improving my F# game engine’s netcode.
If you want to learn more about F# you can check out the Official Website and the most famous blogger FSharp For Fun And Profit.
To learn more about discord’s app development or just to read some frankly excellent documentation check out their Developer Docs. There’s a few hoops to jump through if you do want to get started on your own bot but it’s nothing compared to the challenge of writing one!
Originally published on 2025/03/06