diff --git a/src/ARKServerManager/App.config b/src/ARKServerManager/App.config index 605e2a65..f18196df 100644 --- a/src/ARKServerManager/App.config +++ b/src/ARKServerManager/App.config @@ -804,6 +804,15 @@ 50 + + False + + + asm + + + + diff --git a/src/ARKServerManager/App.xaml.cs b/src/ARKServerManager/App.xaml.cs index 52716ef4..db92093f 100644 --- a/src/ARKServerManager/App.xaml.cs +++ b/src/ARKServerManager/App.xaml.cs @@ -1,5 +1,5 @@ using ArkData; -using Microsoft.WindowsAPICodePack.Dialogs; +using ServerManagerTool.Discord.Interfaces; using NLog; using NLog.Config; using NLog.Targets; @@ -23,6 +23,8 @@ using System.Threading.Tasks; using System.Windows; using System.Xml; using WPFSharp.Globalizer; +using ServerManagerTool.Discord; +using ServerManagerTool.Discord.Enums; namespace ServerManagerTool { @@ -39,6 +41,7 @@ namespace ServerManagerTool public event PropertyChangedEventHandler PropertyChanged; + private CancellationTokenSource _tokenSource; private GlobalizedApplication _globalizer; private bool _applicationStarted; private string _args; @@ -144,6 +147,12 @@ namespace ServerManagerTool } } + public IServerManagerBot ServerManagerBot + { + get; + set; + } + public static void DiscoverMachinePublicIP(bool forceOverride) { if (forceOverride || string.IsNullOrWhiteSpace(Config.Default.MachinePublicIP)) @@ -179,7 +188,11 @@ namespace ServerManagerTool private IList FetchProfiles() { - return ServerManager.Instance.Servers.Select(s => new ServerManagerTool.Plugin.Common.Lib.Profile() { ProfileName = s?.Profile?.ProfileName ?? string.Empty, InstallationFolder = s?.Profile?.InstallDirectory ?? string.Empty }).ToList(); + return ServerManager.Instance.Servers.Select(s => new ServerManagerTool.Plugin.Common.Lib.Profile() + { + ProfileName = s?.Profile?.ProfileName ?? string.Empty, + InstallationFolder = s?.Profile?.InstallDirectory ?? string.Empty + }).ToList(); } public static string GetLogFolder() => IOUtils.NormalizePath(Path.Combine(Config.Default.DataDir, Config.Default.LogsDir)); @@ -218,6 +231,11 @@ namespace ServerManagerTool return LogManager.GetLogger(loggerName); } + private static IList HandleDiscordCommand(CommandType commandType, string channelId, string profileId) + { + return new List() { $"{commandType}; {channelId}; {profileId ?? "no profile"}" }; + } + private static void MigrateSettings() { var installFolder = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); @@ -412,6 +430,7 @@ namespace ServerManagerTool ApplicationStarted = true; + var restartRequired = false; if (string.IsNullOrWhiteSpace(Config.Default.DataDir)) { var dataDirectoryWindow = new DataDirectoryWindow(); @@ -422,6 +441,8 @@ namespace ServerManagerTool { Environment.Exit(0); } + + restartRequired = true; } Config.Default.ConfigDirectory = Path.Combine(Config.Default.DataDir, Config.Default.ProfilesDir); @@ -429,6 +450,11 @@ namespace ServerManagerTool Config.Default.Save(); CommonConfig.Default.Save(); + if (restartRequired) + { + Environment.Exit(0); + } + DataFileDetails.PlayerFileExtension = Config.Default.PlayerFileExtension; DataFileDetails.TribeFileExtension = Config.Default.TribeFileExtension; @@ -446,6 +472,27 @@ namespace ServerManagerTool StartupUri = new Uri("Windows/AutoUpdateWindow.xaml", UriKind.RelativeOrAbsolute); } + + if (Config.Default.DiscordBotEnabled) + { + _tokenSource = new CancellationTokenSource(); + + ServerManagerBot = ServerManagerBotFactory.GetServerManagerBot(); + + Task discordTask = Task.Run(async () => + { + await ServerManagerBot.StartAsync(Config.Default.DiscordBotPrefix, Config.Default.DiscordBotToken, Config.Default.DataDir, HandleDiscordCommand, _tokenSource.Token); + }, _tokenSource.Token) + .ContinueWith(t => { + var message = t.Exception.InnerException is null ? t.Exception.Message : t.Exception.InnerException.Message; + if (message.StartsWith("#")) + { + message = _globalizer.GetResourceString(message.Substring(1)) ?? message.Substring(1); + } + + MessageBox.Show(message, _globalizer.GetResourceString("DiscordBot_ErrorTitle"), MessageBoxButton.OK, MessageBoxImage.Error); + }, TaskContinuationOptions.OnlyOnFaulted); + } } protected override void OnExit(ExitEventArgs e) @@ -486,6 +533,12 @@ namespace ServerManagerTool private void ShutDownApplication() { + if (!(_tokenSource is null)) + { + _tokenSource.Cancel(); + _tokenSource.Dispose(); + } + if (ApplicationStarted) { foreach (var server in ServerManager.Instance.Servers) diff --git a/src/ARKServerManager/Config.Designer.cs b/src/ARKServerManager/Config.Designer.cs index dbac3d1f..2ef36e03 100644 --- a/src/ARKServerManager/Config.Designer.cs +++ b/src/ARKServerManager/Config.Designer.cs @@ -2812,5 +2812,41 @@ namespace ServerManagerTool { return ((string)(this["DefaultDataDirectoryName"])); } } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("False")] + public bool DiscordBotEnabled { + get { + return ((bool)(this["DiscordBotEnabled"])); + } + set { + this["DiscordBotEnabled"] = value; + } + } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("asm")] + public string DiscordBotPrefix { + get { + return ((string)(this["DiscordBotPrefix"])); + } + set { + this["DiscordBotPrefix"] = value; + } + } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("")] + public string DiscordBotToken { + get { + return ((string)(this["DiscordBotToken"])); + } + set { + this["DiscordBotToken"] = value; + } + } } } diff --git a/src/ARKServerManager/Config.settings b/src/ARKServerManager/Config.settings index 72345566..e4710c8e 100644 --- a/src/ARKServerManager/Config.settings +++ b/src/ARKServerManager/Config.settings @@ -779,5 +779,14 @@ asmdata + + False + + + asm + + + + \ No newline at end of file diff --git a/src/ARKServerManager/Globalization/en-US/en-US.xaml b/src/ARKServerManager/Globalization/en-US/en-US.xaml index 49ab73d1..fe8119e6 100644 --- a/src/ARKServerManager/Globalization/en-US/en-US.xaml +++ b/src/ARKServerManager/Globalization/en-US/en-US.xaml @@ -5523,4 +5523,10 @@ There was a problem while performing the server update. This may leave your server in a incomplete state.\r\n\r\nDo you want to continue with the server start, this could cause problems? + + Discord Bot Error + The discord bot requires a valid token so it can log into the discord server\r\nThis can be set in the global settings. + The discord bot prefix contains invalid characters. Only letters and numbers are allowed. + + \ No newline at end of file diff --git a/src/ConanServerManager/App.config b/src/ConanServerManager/App.config index 5b188135..3239cf16 100644 --- a/src/ConanServerManager/App.config +++ b/src/ConanServerManager/App.config @@ -570,6 +570,15 @@ 250 + + False + + + csm + + + + \ No newline at end of file diff --git a/src/ConanServerManager/App.xaml.cs b/src/ConanServerManager/App.xaml.cs index 00eb25a8..9e6c6391 100644 --- a/src/ConanServerManager/App.xaml.cs +++ b/src/ConanServerManager/App.xaml.cs @@ -4,6 +4,9 @@ using NLog.Config; using NLog.Targets; using ServerManagerTool.Common; using ServerManagerTool.Common.Utils; +using ServerManagerTool.Discord; +using ServerManagerTool.Discord.Enums; +using ServerManagerTool.Discord.Interfaces; using ServerManagerTool.Enums; using ServerManagerTool.Lib; using ServerManagerTool.Plugin.Common; @@ -37,6 +40,7 @@ namespace ServerManagerTool public event PropertyChangedEventHandler PropertyChanged; + private CancellationTokenSource _tokenSource; private GlobalizedApplication _globalizer; private bool _applicationStarted; private string _args; @@ -142,6 +146,12 @@ namespace ServerManagerTool } } + public IServerManagerBot ServerManagerBot + { + get; + set; + } + public static void DiscoverMachinePublicIP(bool forceOverride) { if (forceOverride || string.IsNullOrWhiteSpace(Config.Default.MachinePublicIP)) @@ -177,7 +187,11 @@ namespace ServerManagerTool private IList FetchProfiles() { - return ServerManager.Instance.Servers.Select(s => new ServerManagerTool.Plugin.Common.Lib.Profile() { ProfileName = s?.Profile?.ProfileName ?? string.Empty, InstallationFolder = s?.Profile?.InstallDirectory ?? string.Empty }).ToList(); + return ServerManager.Instance.Servers.Select(s => new ServerManagerTool.Plugin.Common.Lib.Profile() + { + ProfileName = s?.Profile?.ProfileName ?? string.Empty, + InstallationFolder = s?.Profile?.InstallDirectory ?? string.Empty + }).ToList(); } public static string GetLogFolder() => IOUtils.NormalizePath(Path.Combine(Config.Default.DataPath, Config.Default.LogsRelativePath)); @@ -216,6 +230,11 @@ namespace ServerManagerTool return LogManager.GetLogger(loggerName); } + private static IList HandleDiscordCommand(CommandType commandType, string channelId, string profileId) + { + return null; + } + private static void MigrateSettings() { var installFolder = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); @@ -395,6 +414,7 @@ namespace ServerManagerTool this.ApplicationStarted = true; + var restartRequired = false; if (string.IsNullOrWhiteSpace(Config.Default.DataPath)) { var dataDirectoryWindow = new DataDirectoryWindow(); @@ -405,6 +425,8 @@ namespace ServerManagerTool { Environment.Exit(0); } + + restartRequired = true; } Config.Default.ConfigPath = Path.Combine(Config.Default.DataPath, Config.Default.ProfilesRelativePath); @@ -412,6 +434,11 @@ namespace ServerManagerTool Config.Default.Save(); CommonConfig.Default.Save(); + if (restartRequired) + { + Environment.Exit(0); + } + if (e.Args.Any(a => a.StartsWith(Constants.ARG_SERVERMONITOR, StringComparison.OrdinalIgnoreCase))) { ServerRuntime.EnableUpdateModStatus = false; @@ -426,6 +453,27 @@ namespace ServerManagerTool StartupUri = new Uri("Windows/AutoUpdateWindow.xaml", UriKind.RelativeOrAbsolute); } + + if (Config.Default.DiscordBotEnabled) + { + _tokenSource = new CancellationTokenSource(); + + ServerManagerBot = ServerManagerBotFactory.GetServerManagerBot(); + + Task discordTask = Task.Run(async () => + { + await ServerManagerBot.StartAsync(Config.Default.DiscordBotPrefix, Config.Default.DiscordBotToken, Config.Default.DataPath, HandleDiscordCommand, _tokenSource.Token); + }, _tokenSource.Token) + .ContinueWith(t => { + var message = t.Exception.InnerException is null ? t.Exception.Message : t.Exception.InnerException.Message; + if (message.StartsWith("#")) + { + message = _globalizer.GetResourceString(message.Substring(1)) ?? message.Substring(1); + } + + MessageBox.Show(message, _globalizer.GetResourceString("DiscordBot_ErrorTitle"), MessageBoxButton.OK, MessageBoxImage.Error); + }, TaskContinuationOptions.OnlyOnFaulted); + } } protected override void OnExit(ExitEventArgs e) @@ -466,6 +514,12 @@ namespace ServerManagerTool private void ShutDownApplication() { + if (!(_tokenSource is null)) + { + _tokenSource.Cancel(); + _tokenSource.Dispose(); + } + if (this.ApplicationStarted) { foreach (var server in ServerManager.Instance.Servers) diff --git a/src/ConanServerManager/Config.Designer.cs b/src/ConanServerManager/Config.Designer.cs index 7239440f..924bbc88 100644 --- a/src/ConanServerManager/Config.Designer.cs +++ b/src/ConanServerManager/Config.Designer.cs @@ -1965,5 +1965,41 @@ namespace ServerManagerTool { return ((string)(this["DefaultDataDirectoryName"])); } } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("False")] + public bool DiscordBotEnabled { + get { + return ((bool)(this["DiscordBotEnabled"])); + } + set { + this["DiscordBotEnabled"] = value; + } + } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("csm")] + public string DiscordBotPrefix { + get { + return ((string)(this["DiscordBotPrefix"])); + } + set { + this["DiscordBotPrefix"] = value; + } + } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("")] + public string DiscordBotToken { + get { + return ((string)(this["DiscordBotToken"])); + } + set { + this["DiscordBotToken"] = value; + } + } } } diff --git a/src/ConanServerManager/Config.settings b/src/ConanServerManager/Config.settings index edc93f78..ed28a58a 100644 --- a/src/ConanServerManager/Config.settings +++ b/src/ConanServerManager/Config.settings @@ -545,5 +545,14 @@ csmdata + + False + + + csm + + + + \ No newline at end of file diff --git a/src/ConanServerManager/Globalization/en-US/en-US.xaml b/src/ConanServerManager/Globalization/en-US/en-US.xaml index c1e939f7..60916cec 100644 --- a/src/ConanServerManager/Globalization/en-US/en-US.xaml +++ b/src/ConanServerManager/Globalization/en-US/en-US.xaml @@ -1193,4 +1193,10 @@ There was a problem while performing the server update. This may leave your server in a incomplete state.\r\n\r\nDo you want to continue with the server start, this could cause problems? + + Discord Bot Error + The discord bot requires a valid token so it can log into the discord server\r\nThis can be set in the global settings. + The discord bot prefix contains invalid characters. Only letters and numbers are allowed. + + \ No newline at end of file diff --git a/src/ServerManager.Discord/Delegates/HandleCommandDelegate.cs b/src/ServerManager.Discord/Delegates/HandleCommandDelegate.cs new file mode 100644 index 00000000..fcbbc5d2 --- /dev/null +++ b/src/ServerManager.Discord/Delegates/HandleCommandDelegate.cs @@ -0,0 +1,7 @@ +using ServerManagerTool.Discord.Enums; +using System.Collections.Generic; + +namespace ServerManagerTool.Discord.Delegates +{ + public delegate IList HandleCommandDelegate(CommandType commandType, string channelId, string profileId); +} diff --git a/src/ServerManager.Discord/DiscordBot.cs b/src/ServerManager.Discord/DiscordBot.cs new file mode 100644 index 00000000..55834e42 --- /dev/null +++ b/src/ServerManager.Discord/DiscordBot.cs @@ -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; + } + } +} diff --git a/src/ServerManager.Discord/Enums/CommandType.cs b/src/ServerManager.Discord/Enums/CommandType.cs new file mode 100644 index 00000000..1da59236 --- /dev/null +++ b/src/ServerManager.Discord/Enums/CommandType.cs @@ -0,0 +1,14 @@ +namespace ServerManagerTool.Discord.Enums +{ + public enum CommandType + { + BackupServer, + ServerInfo, + ServerList, + ServerStatus, + ShutdownServer, + StartServer, + StopServer, + UpdateServer, + } +} diff --git a/src/ServerManager.Discord/Interfaces/IServerManagerBot.cs b/src/ServerManager.Discord/Interfaces/IServerManagerBot.cs new file mode 100644 index 00000000..f1dd6be4 --- /dev/null +++ b/src/ServerManager.Discord/Interfaces/IServerManagerBot.cs @@ -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); + } +} diff --git a/src/ServerManager.Discord/Modules/HelpModule.cs b/src/ServerManager.Discord/Modules/HelpModule.cs new file mode 100644 index 00000000..d4b70531 --- /dev/null +++ b/src/ServerManager.Discord/Modules/HelpModule.cs @@ -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 + { + 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(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()); + } + } +} \ No newline at end of file diff --git a/src/ServerManager.Discord/Modules/ServerCommandModule.cs b/src/ServerManager.Discord/Modules/ServerCommandModule.cs new file mode 100644 index 00000000..63ac7593 --- /dev/null +++ b/src/ServerManager.Discord/Modules/ServerCommandModule.cs @@ -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})"); + } + } + } +} diff --git a/src/ServerManager.Discord/Modules/ServerQueryModule.cs b/src/ServerManager.Discord/Modules/ServerQueryModule.cs new file mode 100644 index 00000000..9b5b7590 --- /dev/null +++ b/src/ServerManager.Discord/Modules/ServerQueryModule.cs @@ -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})"); + } + } + } +} diff --git a/src/ServerManager.Discord/ServerManager.Discord.csproj b/src/ServerManager.Discord/ServerManager.Discord.csproj index 3b657bf3..51a6255d 100644 --- a/src/ServerManager.Discord/ServerManager.Discord.csproj +++ b/src/ServerManager.Discord/ServerManager.Discord.csproj @@ -1,4 +1,4 @@ - + Debug;Release;Debug - Beta @@ -11,5 +11,18 @@ none false - + + $(DefineConstants);DEBUG + + + + + + + + + + + + diff --git a/src/ServerManager.Discord/ServerManagerBot.cs b/src/ServerManager.Discord/ServerManagerBot.cs new file mode 100644 index 00000000..f9530c34 --- /dev/null +++ b/src/ServerManager.Discord/ServerManagerBot.cs @@ -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 + { + { "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() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton(config); + + // Create the service provider + using (var provider = services.BuildServiceProvider()) + { + // Initialize the logging service, startup service, and command handler + provider?.GetRequiredService(); + await provider?.GetRequiredService().StartAsync(); + provider?.GetRequiredService(); + + 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().StopAsync(); + } + } + } +} diff --git a/src/ServerManager.Discord/ServerManagerBotFactory.cs b/src/ServerManager.Discord/ServerManagerBotFactory.cs new file mode 100644 index 00000000..f4143d5a --- /dev/null +++ b/src/ServerManager.Discord/ServerManagerBotFactory.cs @@ -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; + } + } +} diff --git a/src/ServerManager.Discord/Services/CommandHandlerService.cs b/src/ServerManager.Discord/Services/CommandHandlerService.cs new file mode 100644 index 00000000..a4578631 --- /dev/null +++ b/src/ServerManager.Discord/Services/CommandHandlerService.cs @@ -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()); + } + } + } + } +} \ No newline at end of file diff --git a/src/ServerManager.Discord/Services/LoggingService.cs b/src/ServerManager.Discord/Services/LoggingService.cs new file mode 100644 index 00000000..de540d9d --- /dev/null +++ b/src/ServerManager.Discord/Services/LoggingService.cs @@ -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); + } + } +} \ No newline at end of file diff --git a/src/ServerManager.Discord/Services/ShutdownService.cs b/src/ServerManager.Discord/Services/ShutdownService.cs new file mode 100644 index 00000000..65d816f5 --- /dev/null +++ b/src/ServerManager.Discord/Services/ShutdownService.cs @@ -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(); + } + } +} diff --git a/src/ServerManager.Discord/Services/StartupService.cs b/src/ServerManager.Discord/Services/StartupService.cs new file mode 100644 index 00000000..98f63328 --- /dev/null +++ b/src/ServerManager.Discord/Services/StartupService.cs @@ -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); + } + } +} \ No newline at end of file