Discord Bot Scaffolding

This commit is contained in:
Brett Hewitson 2021-12-04 11:39:01 +10:00
parent ceb3ab73c4
commit c4bf4906ea
24 changed files with 1119 additions and 5 deletions

View file

@ -0,0 +1,7 @@
using ServerManagerTool.Discord.Enums;
using System.Collections.Generic;
namespace ServerManagerTool.Discord.Delegates
{
public delegate IList<string> HandleCommandDelegate(CommandType commandType, string channelId, string profileId);
}

View file

@ -0,0 +1,15 @@
using ServerManagerTool.Discord.Delegates;
namespace ServerManagerTool.Discord
{
public static class DiscordBot
{
public const string PREFIX_DELIMITER = "!";
internal static HandleCommandDelegate HandleCommandCallback
{
get;
set;
}
}
}

View file

@ -0,0 +1,14 @@
namespace ServerManagerTool.Discord.Enums
{
public enum CommandType
{
BackupServer,
ServerInfo,
ServerList,
ServerStatus,
ShutdownServer,
StartServer,
StopServer,
UpdateServer,
}
}

View file

@ -0,0 +1,11 @@
using ServerManagerTool.Discord.Delegates;
using System.Threading;
using System.Threading.Tasks;
namespace ServerManagerTool.Discord.Interfaces
{
public interface IServerManagerBot
{
Task StartAsync(string commandPrefix, string discordToken, string dataDirectory, HandleCommandDelegate handleCommandCallback, CancellationToken token);
}
}

View file

@ -0,0 +1,151 @@
using Discord;
using Discord.Commands;
using Microsoft.Extensions.Configuration;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace ServerManagerTool.Discord.Modules
{
[Name("Help")]
public sealed class HelpModule : ModuleBase<SocketCommandContext>
{
private const int MAX_VALUE_LENGTH = 1024;
private readonly CommandService _service;
private readonly IConfigurationRoot _config;
private readonly IServiceProvider _services;
public HelpModule(CommandService service, IConfigurationRoot config, IServiceProvider services)
{
_service = service;
_config = config;
_services = services;
}
[Command("help")]
[Summary("Provides a list of available commands")]
public async Task HelpAsync()
{
var prefix = _config["DiscordSettings:Prefix"];
var builder = new EmbedBuilder()
{
Color = new Color(114, 137, 218),
Description = "These are the commands you can use"
};
foreach (var module in _service.Modules)
{
var moduleName = module.Name;
// create the list of accessible commands
var commands = new List<string>(module.Commands.Count);
foreach (var cmd in module.Commands)
{
var result = await cmd.CheckPreconditionsAsync(Context, _services);
if (!result.IsSuccess)
{
continue;
}
commands.Add($"{prefix}{cmd.Aliases.First()}");
}
// remove all duplicate commands
commands = commands.Distinct().ToList();
var commandString = string.Empty;
foreach (var command in commands)
{
if (string.IsNullOrWhiteSpace(command))
{
continue;
}
var commandToAdd = $"{command}\n";
if (commandString.Length + commandToAdd.Length > MAX_VALUE_LENGTH)
{
// force the output, string would be too long
builder.AddField(x =>
{
x.Name = moduleName;
x.Value = $"{commandString}\n";
x.IsInline = false;
});
// reset the module name and command string
moduleName = $"{module.Name} cont.";
commandString = string.Empty;
}
commandString += commandToAdd;
}
if (!string.IsNullOrWhiteSpace(commandString))
{
builder.AddField(x =>
{
x.Name = moduleName;
x.Value = $"{commandString}\n";
x.IsInline = false;
});
}
}
await ReplyAsync(string.Empty, false, builder.Build());
}
[Command("help")]
[Summary("Searches a list of available commands")]
public async Task HelpAsync(string command)
{
var searchResults = _service.Search(Context, command);
if (!searchResults.IsSuccess)
{
await ReplyAsync($"Sorry, couldn't find a command like **{command}**.");
return;
}
var prefix = _config["DiscordSettings:Prefix"];
var builder = new EmbedBuilder()
{
Color = new Color(114, 137, 218),
Description = $"Here are some commands like **{command}**"
};
foreach (var match in searchResults.Commands)
{
var cmd = match.Command;
var result = await cmd.CheckPreconditionsAsync(Context, _services);
if (!result.IsSuccess)
{
continue;
}
var usage = $"{prefix}{cmd.Aliases.First()}";
if (cmd.Parameters.Count > 0)
{
usage += $" {string.Join(" ", cmd.Parameters.Select(p => p.Name))}";
}
usage += $"\n";
builder.AddField(x =>
{
x.Name = string.Join(", ", cmd.Aliases);
x.Value = $"Summary: {cmd.Summary}\nUsage: {usage}";
x.IsInline = false;
});
}
await ReplyAsync(string.Empty, false, builder.Build());
}
}
}

View file

@ -0,0 +1,207 @@
using Discord.Addons.Interactive;
using Discord.Commands;
using Microsoft.Extensions.Configuration;
using ServerManagerTool.Discord.Enums;
using System;
using System.Threading.Tasks;
namespace ServerManagerTool.Discord.Modules
{
[Name("Server Commands")]
public sealed class ServerCommandModule : InteractiveBase
{
private readonly CommandService _service;
private readonly IConfigurationRoot _config;
public ServerCommandModule(CommandService service, IConfigurationRoot config)
{
_service = service;
_config = config;
}
[Command("backup", RunMode = RunMode.Async)]
[Summary("Perform a backup of the server")]
[Remarks("backup")]
public async Task BackupServerAsync()
{
await BackupServerAsync(null);
}
[Command("backup", RunMode = RunMode.Async)]
[Summary("Perform a backup of the server")]
[Remarks("backup profileId")]
public async Task BackupServerAsync(string profileId)
{
try
{
var channelId = Context?.Channel?.Id.ToString() ?? string.Empty;
var response = DiscordBot.HandleCommandCallback?.Invoke(CommandType.BackupServer, channelId, profileId);
if (response is null || response.Count == 0)
{
await ReplyAsync("No servers associated with this channel.");
}
else
{
foreach (var output in response)
{
await ReplyAsync(output);
await Task.Delay(1000);
}
}
}
catch (Exception ex)
{
await ReplyAsync($"'{Context.Message}' command sent and failed with exception ({ex.Message})");
}
}
[Command("shutdown", RunMode = RunMode.Async)]
[Summary("Shuts down the server properly")]
[Remarks("shutdown")]
public async Task ShutdownServerAsync()
{
await ShutdownServerAsync(null);
}
[Command("shutdown", RunMode = RunMode.Async)]
[Summary("Shuts down the server properly")]
[Remarks("shutdown profileId")]
public async Task ShutdownServerAsync(string profileId)
{
try
{
var channelId = Context?.Channel?.Id.ToString() ?? string.Empty;
var response = DiscordBot.HandleCommandCallback?.Invoke(CommandType.ShutdownServer, channelId, profileId);
if (response is null || response.Count == 0)
{
await ReplyAsync("No servers associated with this channel.");
}
else
{
foreach (var output in response)
{
await ReplyAsync(output);
await Task.Delay(1000);
}
}
}
catch (Exception ex)
{
await ReplyAsync($"'{Context.Message}' command sent and failed with exception ({ex.Message})");
}
}
[Command("start", RunMode = RunMode.Async)]
[Summary("Starts the server")]
[Remarks("start")]
public async Task StartServerAsync()
{
await StartServerAsync(null);
}
[Command("start", RunMode = RunMode.Async)]
[Summary("Starts the server")]
[Remarks("start profileId")]
public async Task StartServerAsync(string profileId)
{
try
{
var channelId = Context?.Channel?.Id.ToString() ?? string.Empty;
var response = DiscordBot.HandleCommandCallback?.Invoke(CommandType.StartServer, channelId, profileId);
if (response is null || response.Count == 0)
{
await ReplyAsync("No servers associated with this channel.");
}
else
{
foreach (var output in response)
{
await ReplyAsync(output);
await Task.Delay(1000);
}
}
}
catch (Exception ex)
{
await ReplyAsync($"'{Context.Message}' command sent and failed with exception ({ex.Message})");
}
}
[Command("stop", RunMode = RunMode.Async)]
[Summary("Forcibly stops the server")]
[Remarks("stop")]
public async Task StopServerAsync()
{
await StopServerAsync(null);
}
[Command("stop", RunMode = RunMode.Async)]
[Summary("Forcibly stops the server")]
[Remarks("stop profileId")]
public async Task StopServerAsync(string profileId)
{
try
{
var channelId = Context?.Channel?.Id.ToString() ?? string.Empty;
var response = DiscordBot.HandleCommandCallback?.Invoke(CommandType.StopServer, channelId, profileId);
if (response is null || response.Count == 0)
{
await ReplyAsync("No servers associated with this channel.");
}
else
{
foreach (var output in response)
{
await ReplyAsync(output);
await Task.Delay(1000);
}
}
}
catch (Exception ex)
{
await ReplyAsync($"'{Context.Message}' command sent and failed with exception ({ex.Message})");
}
}
[Command("update", RunMode = RunMode.Async)]
[Summary("Updates the server")]
[Remarks("update")]
public async Task UpdateServerAsync()
{
await UpdateServerAsync(null);
}
[Command("update", RunMode = RunMode.Async)]
[Summary("Updates the server")]
[Remarks("update profileId")]
public async Task UpdateServerAsync(string profileId)
{
try
{
var channelId = Context?.Channel?.Id.ToString() ?? string.Empty;
var response = DiscordBot.HandleCommandCallback?.Invoke(CommandType.UpdateServer, channelId, profileId);
if (response is null || response.Count == 0)
{
await ReplyAsync("No servers associated with this channel.");
}
else
{
foreach (var output in response)
{
await ReplyAsync(output);
await Task.Delay(1000);
}
}
}
catch (Exception ex)
{
await ReplyAsync($"'{Context.Message}' command sent and failed with exception ({ex.Message})");
}
}
}
}

View file

@ -0,0 +1,125 @@
using Discord.Addons.Interactive;
using Discord.Commands;
using Microsoft.Extensions.Configuration;
using ServerManagerTool.Discord.Enums;
using System;
using System.Threading.Tasks;
namespace ServerManagerTool.Discord.Modules
{
[Name("Server Query")]
public sealed class ServerQueryModule : InteractiveBase
{
private readonly CommandService _service;
private readonly IConfigurationRoot _config;
public ServerQueryModule(CommandService service, IConfigurationRoot config)
{
_service = service;
_config = config;
}
[Command("info", RunMode = RunMode.Async)]
[Summary("Poll server for information")]
[Remarks("info")]
public async Task ServerInfoAsync()
{
await ServerInfoAsync(null);
}
[Command("info", RunMode = RunMode.Async)]
[Summary("Poll server for information")]
[Remarks("info profileId")]
public async Task ServerInfoAsync(string profileId)
{
try
{
var channelId = Context?.Channel?.Id.ToString() ?? string.Empty;
var response = DiscordBot.HandleCommandCallback?.Invoke(CommandType.ServerInfo, channelId, profileId);
if (response is null || response.Count == 0)
{
await ReplyAsync("No servers associated with this channel.");
}
else
{
foreach (var output in response)
{
await ReplyAsync(output);
await Task.Delay(1000);
}
}
}
catch (Exception ex)
{
await ReplyAsync($"'{Context.Message}' command sent and failed with exception ({ex.Message})");
}
}
[Command("list", RunMode = RunMode.Async)]
[Summary("List of all servers associated with this channel")]
[Remarks("list")]
public async Task ServerListAsync()
{
try
{
var channelId = Context?.Channel?.Id.ToString() ?? string.Empty;
var response = DiscordBot.HandleCommandCallback?.Invoke(CommandType.ServerList, channelId, null);
if (response is null || response.Count == 0)
{
await ReplyAsync("No servers associated with this channel.");
}
else
{
foreach (var output in response)
{
await ReplyAsync(output);
await Task.Delay(1000);
}
}
}
catch (Exception ex)
{
await ReplyAsync($"'{Context.Message}' command sent and failed with exception ({ex.Message})");
}
}
[Command("status", RunMode = RunMode.Async)]
[Summary("Poll server for status")]
[Remarks("status")]
public async Task ServerStatusAsync()
{
await ServerStatusAsync(null);
}
[Command("status", RunMode = RunMode.Async)]
[Summary("Poll server for status")]
[Remarks("status profileId")]
public async Task ServerStatusAsync(string profileId)
{
try
{
var channelId = Context?.Channel?.Id.ToString() ?? string.Empty;
var response = DiscordBot.HandleCommandCallback?.Invoke(CommandType.ServerStatus, channelId, profileId);
if (response is null || response.Count == 0)
{
await ReplyAsync("No servers associated with this channel.");
}
else
{
foreach (var output in response)
{
await ReplyAsync(output);
await Task.Delay(1000);
}
}
}
catch (Exception ex)
{
await ReplyAsync($"'{Context.Message}' command sent and failed with exception ({ex.Message})");
}
}
}
}

View file

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup Label="Globals">
<Configurations>Debug;Release;Debug - Beta</Configurations>
</PropertyGroup>
@ -11,5 +11,18 @@
<DebugType>none</DebugType>
<DebugSymbols>false</DebugSymbols>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug - Beta|AnyCPU'">
<DefineConstants>$(DefineConstants);DEBUG</DefineConstants>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Discord.Addons.Interactive" Version="2.0.0" />
<PackageReference Include="Discord.Net" Version="2.4.0" />
<PackageReference Include="Discord.Net.Providers.WS4Net" Version="2.4.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="2.1.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="2.1.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="2.1.0" />
</ItemGroup>
<ItemGroup>
<Folder Include="Services\" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,137 @@
using Discord;
using Discord.Addons.Interactive;
using Discord.Commands;
using Discord.Net.Providers.WS4Net;
using Discord.WebSocket;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ServerManagerTool.Discord.Delegates;
using ServerManagerTool.Discord.Interfaces;
using ServerManagerTool.Discord.Services;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace ServerManagerTool.Discord
{
public sealed class ServerManagerBot : IServerManagerBot
{
internal ServerManagerBot()
{
}
private bool Started
{
get;
set;
}
public async Task StartAsync(string commandPrefix, string discordToken, string dataDirectory, HandleCommandDelegate handleCommandCallback, CancellationToken token)
{
if (Started)
{
return;
}
Started = true;
if (string.IsNullOrWhiteSpace(commandPrefix) || string.IsNullOrWhiteSpace(discordToken))
{
return;
}
if (commandPrefix.Any(c => !char.IsLetterOrDigit(c)))
{
throw new Exception("#DiscordBot_InvalidPrefixError");
}
if (!commandPrefix.EndsWith(DiscordBot.PREFIX_DELIMITER))
{
commandPrefix += DiscordBot.PREFIX_DELIMITER;
}
var settings = new Dictionary<string, string>
{
{ "DiscordSettings:Prefix", commandPrefix },
{ "DiscordSettings:Token", discordToken },
{ "ServerManager:DataDirectory", dataDirectory }
};
// Begin building the configuration file
var config = new ConfigurationBuilder()
.AddInMemoryCollection(settings)
.Build();
var socketConfig = new DiscordSocketConfig
{
#if DEBUG
LogLevel = LogSeverity.Verbose,
#else
LogLevel = LogSeverity.Info,
#endif
// Tell Discord.Net to cache 1000 messages per channel
MessageCacheSize = 1000,
};
if (Environment.OSVersion.Version < new Version(6, 2))
{
// windows 7 or early
socketConfig.WebSocketProvider = WS4NetProvider.Instance;
}
var commandConfig = new CommandServiceConfig
{
// Force all commands to run async
DefaultRunMode = RunMode.Async,
#if DEBUG
LogLevel = LogSeverity.Verbose,
#else
LogLevel = LogSeverity.Info,
#endif
};
// Build the service provider
var services = new ServiceCollection()
// Add the discord client to the service provider
.AddSingleton(new DiscordSocketClient(socketConfig))
// Add the command service to the service provider
.AddSingleton(new CommandService(commandConfig))
// Add remaining services to the provider
.AddSingleton<CommandHandlerService>()
.AddSingleton<InteractiveService>()
.AddSingleton<LoggingService>()
.AddSingleton<StartupService>()
.AddSingleton<ShutdownService>()
.AddSingleton<Random>()
.AddSingleton(config);
// Create the service provider
using (var provider = services.BuildServiceProvider())
{
// Initialize the logging service, startup service, and command handler
provider?.GetRequiredService<LoggingService>();
await provider?.GetRequiredService<StartupService>().StartAsync();
provider?.GetRequiredService<CommandHandlerService>();
DiscordBot.HandleCommandCallback = handleCommandCallback;
try
{
// Prevent the application from closing
await Task.Delay(Timeout.Infinite, token);
}
catch (TaskCanceledException)
{
Debug.WriteLine("Task Canceled");
}
catch (OperationCanceledException)
{
Debug.WriteLine("Operation Canceled");
}
await provider?.GetRequiredService<ShutdownService>().StopAsync();
}
}
}
}

View file

@ -0,0 +1,19 @@
using ServerManagerTool.Discord.Interfaces;
namespace ServerManagerTool.Discord
{
public static class ServerManagerBotFactory
{
private static IServerManagerBot _serverManagerBot;
public static IServerManagerBot GetServerManagerBot()
{
if (_serverManagerBot is null)
{
_serverManagerBot = new ServerManagerBot();
}
return _serverManagerBot;
}
}
}

View file

@ -0,0 +1,65 @@
using Discord.Commands;
using Discord.WebSocket;
using Microsoft.Extensions.Configuration;
using System;
using System.Threading.Tasks;
namespace ServerManagerTool.Discord.Services
{
public class CommandHandlerService
{
private readonly DiscordSocketClient _discord;
private readonly CommandService _commands;
private readonly IConfigurationRoot _config;
private readonly IServiceProvider _provider;
public CommandHandlerService(DiscordSocketClient discord, CommandService commands, IConfigurationRoot config, IServiceProvider provider)
{
_discord = discord;
_commands = commands;
_config = config;
_provider = provider;
_discord.MessageReceived += OnMessageReceivedAsync;
}
private async Task OnMessageReceivedAsync(SocketMessage s)
{
// Ensure the message is from a user/bot
var msg = s as SocketUserMessage;
if (msg is null)
{
return;
}
// Ignore self when checking commands
if (msg.Author == _discord.CurrentUser)
{
return;
}
//Tell bot to ignore itself.
if (msg.Author.IsBot)
{
return;
}
// Create the command context
var context = new SocketCommandContext(_discord, msg);
// Check if the message has a valid command prefix
var argPos = 0;
if (msg.HasStringPrefix(_config["DiscordSettings:Prefix"], ref argPos) || msg.HasMentionPrefix(_discord.CurrentUser, ref argPos))
{
// Execute the command
var result = await _commands.ExecuteAsync(context, argPos, _provider);
if (!result.IsSuccess)
{
// If not successful, reply with the error.
await context.Channel.SendMessageAsync(result.ToString());
}
}
}
}
}

View file

@ -0,0 +1,57 @@
using Discord;
using Discord.Commands;
using Discord.WebSocket;
using Microsoft.Extensions.Configuration;
using System;
using System.IO;
using System.Threading.Tasks;
namespace ServerManagerTool.Discord.Services
{
public class LoggingService
{
private readonly DiscordSocketClient _discord;
private readonly CommandService _commands;
private readonly IConfigurationRoot _config;
private string LogDirectory { get; }
private string LogFile => Path.Combine(LogDirectory, $"ServerManager_DiscordBot.{DateTime.Now:yyyyMMdd}.log");
public LoggingService(DiscordSocketClient discord, CommandService commands, IConfigurationRoot config)
{
_discord = discord;
_commands = commands;
_config = config;
// Get the data directory from the config file
var rootDirectory = _config["ServerManager:DataDirectory"] ?? AppContext.BaseDirectory;
LogDirectory = Path.Combine(rootDirectory, "logs");
_discord.Log += OnLogAsync;
_commands.Log += OnLogAsync;
}
private async Task OnLogAsync(LogMessage message)
{
// Create the log directory if it doesn't exist
if (!Directory.Exists(LogDirectory))
{
Directory.CreateDirectory(LogDirectory);
}
// Create today's log file if it doesn't exist
if (!File.Exists(LogFile))
{
File.Create(LogFile).Dispose();
}
var logText = $"{DateTime.Now:HH:mm:ss:ffff} [{message.Severity}] {message.Source}: {message.Exception?.ToString() ?? message.Message}";
// Write the log text to a file
File.AppendAllText(LogFile, logText + "\n");
// Write the log text to the console
await Console.Out.WriteLineAsync(logText);
}
}
}

View file

@ -0,0 +1,21 @@
using Discord.WebSocket;
using System.Threading.Tasks;
namespace ServerManagerTool.Discord.Services
{
public class ShutdownService
{
private readonly DiscordSocketClient _discord;
public ShutdownService(DiscordSocketClient discord)
{
_discord = discord;
}
public async Task StopAsync()
{
await _discord.StopAsync();
await _discord.LogoutAsync();
}
}
}

View file

@ -0,0 +1,45 @@
using Discord;
using Discord.Commands;
using Discord.WebSocket;
using Microsoft.Extensions.Configuration;
using System;
using System.Reflection;
using System.Threading.Tasks;
namespace ServerManagerTool.Discord.Services
{
public class StartupService
{
private readonly DiscordSocketClient _discord;
private readonly CommandService _commands;
private readonly IConfigurationRoot _config;
private readonly IServiceProvider _provider;
public StartupService(DiscordSocketClient discord, CommandService commands, IConfigurationRoot config, IServiceProvider provider)
{
_discord = discord;
_commands = commands;
_config = config;
_provider = provider;
}
public async Task StartAsync()
{
// Get the discord token from the config file
var discordToken = _config["DiscordSettings:Token"];
if (string.IsNullOrWhiteSpace(discordToken))
{
throw new Exception("#DiscordBot_MissingTokenError");
}
// Login to discord
await _discord.LoginAsync(TokenType.Bot, discordToken);
// Connect to the websocket
await _discord.StartAsync();
// Load commands and modules into the command service
await _commands.AddModulesAsync(Assembly.GetExecutingAssembly(), _provider);
}
}
}