Creating a Discord chat bot in .NET
Update: due to popular demand, I've added Tyrion as a Github repo.
Update 2: due to more popular demand, I even made the repo public. :) Sorry about that.
Intro
Discord is something I have only vaguely heard about and when a friend told me he used it for chat with friends, I installed it, too. I was pleasantly surprised to see it is a very usable and free chat application, which combines feature of IRC, other messenger applications and a bit of Slack. You can create servers and add channels to them, for example, where you can determine the rights of people and so on. What sets Discord apart from anything, perhaps other than Slack, is the level of "integration", the ability to programatically interact with it. So I create a "bot", a program which stays active and responds to user chat messages and can take action. This post is about how to do that.
Before you implement a bot you obviously need:
- a Discord account
- (optional but recommended) install Discord application on your computer
- create a server
- create a bot for the server
All of this has been done to death and you can follow the links above to learn how to do it. Before we continue, a little something that might not be obvious: you can edit a Discord chat invite so that it never expires, as it is the one on this blog now.
Writing code
One can write a bot in a multitude of programming languages, but I am a .NET dev, so Discord.NET it is. Note that this is an "unofficial" library, so it may not (and it is not) completely in sync with all the options that the Discord API provides. One such feature, for example, is multiple attachments to a message. But I digress.
Since my blog is also written in ASP.NET Core, it made sense to add the bot code to that. Also, in order to make it all clean code, I will use dependency injection as much as possible and use the built-in system for commands, even if it is quite rudimentary.
Step 1 - making dependencies available
We are going to need these dependencies:
- DiscordSocketClient - the client to connect to Discord
- CommandService - the service managing commands
- BotSettings - a class used to hold settings and configuration
- BotService - the bot itself, which we are going to make implement IHostedService so we can add it as a hosted service in ASP.Net
In order to keep things separated, I will not add all of this in Startup, instead encapsulating them into a Bootstrap class:
public static class Bootstrap
{
public static IWebHostBuilder UseDiscordBot(this IWebHostBuilder builder)
{
return builder.ConfigureServices(services =>
{
services
.AddSingleton<BotSettings>()
.AddSingleton<DiscordSocketClient>()
.AddSingleton<CommandService>()
.AddHostedService<BotService>();
});
}
}
This allows me to add the bot simply in CreateWebHostBuilder as:
WebHost.CreateDefaultBuilder(args)
.UseStartup<Startup>()
.UseKestrel(a => a.AddServerHeader = false)
.UseDiscordBot();
Step 2 - the settings
The BotSettings class will be used not only to hold information, but also communicate it between classes. Each Discord chat bot needs an access token to connect and we can add that as a configuration value in appsettings.config:
{
...
"DiscordBot": {
"Token":"<the token value>"
},
...
}
public class BotSettings
{
public BotSettings(IConfiguration config, IHostingEnvironment hostingEnvironment)
{
Token = config.GetValue<string>("DiscordBot:Token");
RootPath = hostingEnvironment.WebRootPath;
BotEnabled = true;
}
public string Token { get; }
public string RootPath { get; }
public bool BotEnabled { get; set; }
}
As you can see, no fancy class for getting the config, nor do we use IOptions or anything like that. We only need to get the token value once, let's keep it simple. I've added the RootPath because you might want to use it to access files on the local file system. The other property is a setting for enabling or disabling the functionality of the bot.
Step 3 - the bot skeleton
Here is the skeleton for a bot. It doesn't change much outside the MessageReceived and CommandReceived code.
public class BotService : IHostedService, IDisposable
{
private readonly DiscordSocketClient _client;
private readonly CommandService _commandService;
private readonly IServiceProvider _services;
private readonly BotSettings _settings;
public BotService(DiscordSocketClient client,
CommandService commandService,
IServiceProvider services,
BotSettings settings)
{
_client = client;
_commandService = commandService;
_services = services;
_settings = settings;
}
// The hosted service has started
public async Task StartAsync(CancellationToken cancellationToken)
{
_client.Ready += Ready;
_client.MessageReceived += MessageReceived;
_commandService.CommandExecuted += CommandExecuted;
_client.Log += Log;
_commandService.Log += Log;
// look for classes implementing ModuleBase to load commands from
await _commandService.AddModulesAsync(Assembly.GetEntryAssembly(), _services);
// log in to Discord, using the provided token
await _client.LoginAsync(TokenType.Bot, _settings.Token);
// start bot
await _client.StartAsync();
}
// logging
private async Task Log(LogMessage arg)
{
// do some logging
}
// bot has connected and it's ready to work
private async Task Ready()
{
// some random stuff you can do once the bot is online:
// set status to online
await _client.SetStatusAsync(UserStatus.Online);
// Discord started as a game chat service, so it has the option to show what games you are playing
// Here the bot will display "Playing dead" while listening
await _client.SetGameAsync("dead", "https://siderite.dev", ActivityType.Listening);
}
private async Task MessageReceived(SocketMessage msg)
{
// message retrieved
}
private async Task CommandExecuted(Optional<CommandInfo> command, ICommandContext context, IResult result)
{
// a command execution was attempted
}
// the hosted service is stopping
public async Task StopAsync(CancellationToken cancellationToken)
{
await _client.SetGameAsync(null);
await _client.SetStatusAsync(UserStatus.Offline);
await _client.StopAsync();
_client.Log -= Log;
_client.Ready -= Ready;
_client.MessageReceived -= MessageReceived;
_commandService.Log -= Log;
_commandService.CommandExecuted -= CommandExecuted;
}
public void Dispose()
{
_client?.Dispose();
}
}
Step 4 - adding commands
In order to add commands to the bot, you must do the following:
- create a class to inherit from ModuleBase
- add public methods that are decorated with the CommandAttribute
- don't forget to call commandService.AddModuleAsync like above
Here is an example of an enable/disable command class:
public class BotCommands:ModuleBase
{
private readonly BotSettings _settings;
public BotCommands(BotSettings settings)
{
_settings = settings;
}
[Command("bot")]
public async Task Bot([Remainder]string rest)
{
if (string.Equals(rest, "enable",StringComparison.OrdinalIgnoreCase))
{
_settings.BotEnabled = true;
}
if (string.Equals(rest, "disable", StringComparison.OrdinalIgnoreCase))
{
_settings.BotEnabled = false;
}
await this.Context.Channel.SendMessageAsync("Bot is "
+ (_settings.BotEnabled ? "enabled" : "disabled"));
}
}
When the bot command will be issued, then the state of the bot will be sent as a message to the chat. If the parameter of the command is enable or disable, the state will also be changed accordingly.
Yet, in order for this command to work, we need to add code to the bot MessageReceived method:
private async Task MessageReceived(SocketMessage msg)
{
// do not process bot messages or system messages
if (msg.Author.IsBot || msg.Source != MessageSource.User) return;
// only process this type of message
var message = msg as SocketUserMessage;
if (message == null) return;
// match the message if it starts with R2D2
var match = Regex.Match(message.Content, @"^\s*R2D2\s+", RegexOptions.IgnoreCase);
int? pos = null;
if (match.Success)
{
// this is an R2D2 command, everything after the match is the command text
pos = match.Length;
}
else if (message.Channel is IPrivateChannel)
{
// this is a command sent directly to the private channel of the bot,
// don't expect to start with R2D2 at all, just execute it
pos = 0;
}
if (pos.HasValue)
{
// this is a command, execute it
var context = new SocketCommandContext(_client, message);
await _commandService.ExecuteAsync(context, message.Content.Substring(pos.Value), _services);
}
else
{
// processing of messages that are not commands
if (_settings.BotEnabled)
{
// if the bot is enabled and people are talking about it, show an image and say "beep beep"
if (message.Content.Contains("R2D2",StringComparison.OrdinalIgnoreCase))
{
await message.Channel.SendFileAsync(_settings.RootPath + "/img/R2D2.gif", "Beep beep!", true);
}
}
}
}
This code will forward commands to the command service if message starts with R2D2, else, if bot is enabled, will send replies with the R2D2 picture and saying beep beep to messages that contain R2D2.
Step 5 - handling command results
Command execution may end in one of three states:
- command is not recognized
- command has failed
- command has succeeded
Here is a CommandExecuted event handler that takes these into account:
private async Task CommandExecuted(Optional<CommandInfo> command, ICommandContext context, IResult result)
{
// if a command isn't found
if (!command.IsSpecified)
{
await context.Message.AddReactionAsync(new Emoji("🤨")); // eyebrow raised emoji
return;
}
// log failure to the console
if (!result.IsSuccess)
{
await Log(new LogMessage(LogSeverity.Error, nameof(CommandExecuted), $"Error: {result.ErrorReason}"));
return;
}
// react to message
await context.Message.AddReactionAsync(new Emoji("🤖")); // robot emoji
}
Note that the command info object does not expose a result value, other than success and failure.
Conclusion
This post has shown you how to create a Discord chat bot in .NET and add it to an ASP.Net Core web site as a hosted service. You may see the result by joining this blog's chat and giving commands to Tyr, the chat's bot:
- play
- fart
- use metric or imperial units in messages
- use Yahoo Messenger emoticons in messages
- whatever else I will add in it when I get in the mood :)
Comments
Donno. Maybe with a bot that is also an API. Not very familiar with Discord APIs.
Sideritehey there, are there possibilitys to send discord commands from the webclient?
Niels