diff --git a/README.md b/README.md index e982eca..89c2b5c 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,10 @@ Banlist: banned people are unable to join the server, default: false PersistShines/Moons: Allows the server to remember moon progress across crashes/restarts ### Discord -Note: Token and LogChannel needs to a string puts "" around it +Note: Token, AdminChannel (formerly known as "LogChannel") and CommandChannel need to have quotes "" around it Token: the token of the bot you want to load into, default: null Prefix: the bot prefix to be used, default: $ -LogChannel: logs the server console to that channel, default: null \ No newline at end of file +CommandChannel: allows discord commands, default: null +LogChannel/(AdminChannel): allows discord commands & logs the server console to that channel, default: null +LogCommands: log all executed commands to log handlers (discord, console), default: false +FilterOutNonIssueWarnings: filter out the nonsense warnings/errors? default: true \ No newline at end of file diff --git a/Server/DiscordBot.cs b/Server/DiscordBot.cs index cf1457a..8200569 100644 --- a/Server/DiscordBot.cs +++ b/Server/DiscordBot.cs @@ -1,160 +1,274 @@ -using DSharpPlus; -using DSharpPlus.Entities; -using Microsoft.Extensions.Logging; +using Discord; +using Discord.Commands; +using Discord.Net; +using Discord.WebSocket; +using Newtonsoft.Json.Linq; using Shared; namespace Server; -public class DiscordBot { - private DiscordClient? DiscordClient; - private string? Token; - private Settings.DiscordTable Config => Settings.Instance.Discord; - private string Prefix => Config.Prefix; - private readonly Logger Logger = new Logger("Discord"); - private DiscordChannel? CommandChannel; - private DiscordChannel? LogChannel; - private bool Reconnecting; +public class DiscordBot +{ + private readonly Logger logger = new Logger("Discord"); + private Settings.DiscordTable localSettings = Settings.Instance.Discord; + private DiscordSocketClient? client = null; + private SocketTextChannel? logChannel = null; + private bool firstInitTriggered = false; - public DiscordBot() { - Token = Config.Token; - Logger.AddLogHandler(Log); - CommandHandler.RegisterCommand("dscrestart", _ => { - // this should be async'ed but i'm lazy - Reconnecting = true; - Task.Run(Reconnect); - return "Restarting Discord bot"; + //check how this works with neither, one or the other, or both channels set. + + public DiscordBot() + { + CommandHandler.RegisterCommand("dscrestart", _ => + { + //Task.Run is to fix deadlock (dispose can only be finalized if all discord callbacks are returned, + //and since this delegate is called directly from a callback, it would cause a deadlock). + Task.Run(() => + { + Stop(); +#pragma warning disable CS4014 + Init(); +#pragma warning restore CS4014 + }); + return localSettings.Enabled ? "Restarting Discord bot..." : "The discord bot is disabled in settings.json (no action was taken)."; }); - if (Config.Token == null) return; - if (Config.CommandChannel == null) - Logger.Warn("You probably should set your CommandChannel in settings.json"); - if (Config.LogChannel == null) - Logger.Warn("You probably should set your LogChannel in settings.json"); - Settings.LoadHandler += SettingsLoadHandler; + if (localSettings.Enabled) + logger.Info("Starting discord bot (ctor)"); + Settings.LoadHandler += OnLoadSettings; + Logger.AddLogHandler(LogToDiscordLogChannel); } - private async Task Reconnect() { - if (DiscordClient != null) // usually null prop works, not here though...` - await DiscordClient.DisconnectAsync(); - await Run(); - } - - private async void SettingsLoadHandler() { - if (DiscordClient == null || Token != Config.Token) { - await Run(); + //this nonsense is to prevent race conditions from starting multiple bots. + //this would be a great thing to instead simply have an "await Init()" put + //in the ctor (but awaits can't be there), and Task.Wait shouldn't be used that way. + private object firstInitLockObj = new object(); + public async Task FirstInit() + { + lock (firstInitLockObj) + { + if (firstInitTriggered) + return; + firstInitTriggered = true; } + await Init(); + } - if (DiscordClient == null) { - Logger.Error(new NullReferenceException("Discord client not setup yet!")); + private async Task Init() + { + if (client != null || !localSettings.Enabled) + { + return; //Either: the discord bot is disabled, or: this is bad if the client ever crashes and + //isn't reassigned to null, but we don't want multiple instances of the bot running at the same time. + } + if (localSettings.Token == null || (localSettings.AdminChannel == null && localSettings.CommandChannel == null)) + { + //no point trying to run anything if there's no discord token and/or no channel for a user to interact with the bot through. + logger.Error("Tried to run the discord bot, but the Token and/or communication channels are not specified in the settings."); return; } + client = new DiscordSocketClient( + new DiscordSocketConfig() + { + LogLevel = LogSeverity.Warning + }); - if (Config.CommandChannel != null) { - try { - CommandChannel = await DiscordClient.GetChannelAsync(ulong.Parse(Config.CommandChannel)); - } catch (Exception e) { - Logger.Error($"Failed to get command channel \"{Config.CommandChannel}\""); - Logger.Error(e); + client.Log += async (a) => await Task.Run(() => + { + //as time goes on, we may encounter logged info that we literally don't care about. Fill out an if statement to properly + //filter it out to avoid logging it to discord. + if (localSettings.FilterOutNonIssueWarnings) + { + string[] disinterestedMessages = + { //these messages happen sometimes, and are of no concern. + "Server requested a reconnect", + "The remote party closed the WebSocket connection without completing the close handshake", + "without listening to any events related to that intent, consider removing the intent from" + }; + foreach (string dis in disinterestedMessages) + { + if ((a.Exception?.ToString().Contains(dis) ?? false) || + (a.Message?.Contains(dis) ?? false)) + { + return; + } + } + } + string message = a.Message ?? string.Empty + (a.Exception != null ? "Exception: " + a.Exception.ToString() : ""); //TODO: this crashes + ConsoleColor col; + switch (a.Severity) + { + default: + case LogSeverity.Info: + case LogSeverity.Debug: + col = ConsoleColor.White; + break; + case LogSeverity.Critical: + case LogSeverity.Error: + col = ConsoleColor.Red; + break; + case LogSeverity.Warning: + col = ConsoleColor.Yellow; + break; + } + + LogToDiscordLogChannel($"Discord: {a.Source}", a.Severity.ToString(), message, col); + }); + try + { + SemaphoreSlim wait = new SemaphoreSlim(0); +#pragma warning disable CS1998 + client.Ready += async () => +#pragma warning restore CS1998 + { + wait.Release(); + }; + await client.LoginAsync(Discord.TokenType.Bot, localSettings.Token); + await client.StartAsync(); + await wait.WaitAsync(); + //we need to wait for the ready event before we can do any of this nonsense. + logChannel = (ulong.TryParse(localSettings.AdminChannel, out ulong lcid) ? (client != null ? await client.GetChannelAsync(lcid) : null) : null) as SocketTextChannel; + client!.MessageReceived += (m) => HandleCommandAsync(m); + logger.Info("Discord bot has been initialized."); + } + catch (Exception e) + { + logger.Error(e); + } + } + + private void Stop() + { + try + { + if (client != null) + { + if (!client.StopAsync().Wait(60000)) + logger.Warn("Tried to stop the discord bot, but attempt took >60 seconds, so it failed!"); + } + client?.Dispose(); + } + catch { /*lol (lmao)*/ } + client = null; + logChannel = null; + localSettings = Settings.Instance.Discord; + } + + private async void LogToDiscordLogChannel(string source, string level, string text, ConsoleColor color) + { + logChannel = (ulong.TryParse(localSettings.AdminChannel, out ulong lcid) ? (client != null ? await client.GetChannelAsync(lcid) : null) : null) as SocketTextChannel; + if (logChannel != null) + { + try + { + switch (color) + { + //I looked into other hacky methods of doing more colors, the rest seemed unreliable. + default: + foreach (string mesg in SplitMessage(Logger.PrefixNewLines(text, $"{level} [{source}]"), 1994)) //room for 6 '`' + await logChannel.SendMessageAsync($"```{mesg}```"); + break; + case ConsoleColor.Yellow: //this is actually light blue now (discord changed it awhile ago). + foreach (string mesg in SplitMessage(Logger.PrefixNewLines(text, $"{level} [{source}]"), 1990)) //room for 6 '`', "fix" and "\n" + await logChannel.SendMessageAsync($"```fix\n{mesg}```"); + break; + case ConsoleColor.Red: + foreach (string mesg in SplitMessage(Logger.PrefixNewLines(text, $"-{level} [{source}]"), 1989)) //room for 6 '`', "diff" and "\n" + await logChannel.SendMessageAsync($"```diff\n{mesg}```"); + break; + case ConsoleColor.Green: + foreach (string mesg in SplitMessage(Logger.PrefixNewLines(text, $"+{level} [{source}]"), 1989)) //room for 6 '`', "diff" and "\n" + await logChannel.SendMessageAsync($"```diff\n{mesg}```"); + break; + } + } + catch (Exception e) + { + // don't log again, it'll just stack overflow the server! + await Console.Error.WriteLineAsync("Exception in discord logger"); + await Console.Error.WriteLineAsync(e.ToString()); } } + } - if (Config.LogChannel != null) { - try { - LogChannel = await DiscordClient.GetChannelAsync(ulong.Parse(Config.LogChannel)); - } catch (Exception e) { - Logger.Error($"Failed to get log channel \"{Config.LogChannel}\""); - Logger.Error(e); + private async Task HandleCommandAsync(SocketMessage arg) + { + if (arg is not SocketUserMessage) + return; //idk what to do in this circumstance. + if ((arg.Channel.Id.ToString() == localSettings.CommandChannel || arg.Channel.Id.ToString() == localSettings.AdminChannel) && !arg.Author.IsBot) + { + string message = (await arg.Channel.GetMessageAsync(arg.Id)).Content; + //run command + try + { + string? args = null; + if (string.IsNullOrEmpty(localSettings.Prefix)) + { + args = message; + } + else if (message.StartsWith(localSettings.Prefix)) + { + args = message[localSettings.Prefix.Length..]; + } + else if (message.StartsWith($"<@{client!.CurrentUser.Id}>")) + { + args = message[client!.CurrentUser.Mention.Length..].TrimStart(); + } + if (args != null) + { + await arg.Channel.TriggerTypingAsync(); + string resp = string.Join('\n', CommandHandler.GetResult(args).ReturnStrings); + if (localSettings.LogCommands) + { + logger.Info($"\"{arg.Author.Username}\" ran the command: \"{message}\" via discord"); + } + foreach (string mesg in SplitMessage(resp)) + await (arg as SocketUserMessage).ReplyAsync(mesg); + } } + catch (Exception e) + { + logger.Error(e); + } + } + else + { + //don't respond to commands not in these channels, and no bots + //probably don't print out error message because DDOS + } + } + + private void OnLoadSettings() + { + Settings.DiscordTable oldSettings = localSettings; + localSettings = Settings.Instance.Discord; + + if (localSettings.CommandChannel == null) + logger.Warn("You probably should set your CommandChannel in settings.json"); + if (localSettings.AdminChannel == null) + logger.Warn("You probably should set your AdminChannel in settings.json"); + + if (oldSettings.Token != localSettings.Token || oldSettings.AdminChannel != localSettings.AdminChannel || oldSettings.CommandChannel != localSettings.CommandChannel) + { + //start over fresh (there might be a more intelligent way to do this without restarting the bot if only the log/command channel changed, but I'm lazy. + Stop(); +#pragma warning disable CS4014 + Init(); +#pragma warning restore CS4014 } } private static List SplitMessage(string message, int maxSizePerElem = 2000) { List result = new List(); - for (int i = 0; i < message.Length; i += maxSizePerElem) + for (int i = 0; i < message.Length; i += maxSizePerElem) { result.Add(message.Substring(i, message.Length - i < maxSizePerElem ? message.Length - i : maxSizePerElem)); } return result; } - private async void Log(string source, string level, string text, ConsoleColor _) { - try { - if (DiscordClient != null && LogChannel != null) { - foreach (string mesg in SplitMessage(Logger.PrefixNewLines(text, $"{level} [{source}]"), 1994)) //room for 6 '`' - await DiscordClient.SendMessageAsync(LogChannel, $"```{mesg}```"); - } - } catch (Exception e) { - // don't log again, it'll just stack overflow the server! - if (Reconnecting) return; // skip if reconnecting - await Console.Error.WriteLineAsync("Exception in discord logger"); - await Console.Error.WriteLineAsync(e.ToString()); - } - } - - public async Task Run() { - Token = Config.Token; - DiscordClient?.Dispose(); - if (Config.Token == null) { - DiscordClient = null; - return; - } - - try { - DiscordClient = new DiscordClient(new DiscordConfiguration { - Token = Config.Token, - MinimumLogLevel = LogLevel.None - }); - await DiscordClient.ConnectAsync(new DiscordActivity("Hide and Seek", ActivityType.Competing)); - SettingsLoadHandler(); - Logger.Info( - $"Discord bot logged in as {DiscordClient.CurrentUser.Username}#{DiscordClient.CurrentUser.Discriminator}"); - Reconnecting = false; - string mentionPrefix = $"{DiscordClient.CurrentUser.Mention}"; - DiscordClient.MessageCreated += async (_, args) => { - if (args.Author.IsCurrent) return; //dont respond to commands from ourselves (prevent "sql-injection" esq attacks) - //prevent commands via dm and non-public channels - if (CommandChannel == null) { - if (args.Channel is DiscordDmChannel) - return; //no dm'ing the bot allowed! - } - else if (args.Channel.Id != CommandChannel.Id && (LogChannel != null && args.Channel.Id != LogChannel.Id)) - return; - //run command - try { - DiscordMessage msg = args.Message; - string? resp = null; - if (string.IsNullOrEmpty(Prefix)) { - await msg.Channel.TriggerTypingAsync(); - resp = string.Join('\n', CommandHandler.GetResult(msg.Content).ReturnStrings); - } else if (msg.Content.StartsWith(Prefix)) { - await msg.Channel.TriggerTypingAsync(); - resp = string.Join('\n', CommandHandler.GetResult(msg.Content[Prefix.Length..]).ReturnStrings); - } else if (msg.Content.StartsWith(mentionPrefix)) { - await msg.Channel.TriggerTypingAsync(); - resp = string.Join('\n', CommandHandler.GetResult(msg.Content[mentionPrefix.Length..].TrimStart()).ReturnStrings); - } - if (resp != null) - { - foreach (string mesg in SplitMessage(resp)) - await msg.RespondAsync(mesg); - } - } catch (Exception e) { - Logger.Error(e); - } - }; - DiscordClient.ClientErrored += (_, args) => { - Logger.Error("Discord client caught an error in handler!"); - Logger.Error(args.Exception); - return Task.CompletedTask; - }; - DiscordClient.SocketErrored += (_, args) => { - Logger.Error("Discord client caught an error on socket!"); - Logger.Error(args.Exception); - return Task.CompletedTask; - }; - } catch (Exception e) { - Logger.Error("Exception occurred in discord runner!"); - Logger.Error(e); - } + ~DiscordBot() + { + Stop(); } } diff --git a/Server/Program.cs b/Server/Program.cs index 7b98085..ac7eda1 100644 --- a/Server/Program.cs +++ b/Server/Program.cs @@ -14,7 +14,7 @@ CancellationTokenSource cts = new CancellationTokenSource(); bool restartRequested = false; Logger consoleLogger = new Logger("Console"); DiscordBot bot = new DiscordBot(); -await bot.Run(); +await bot.FirstInit(); async Task PersistShines() { diff --git a/Server/Server.cs b/Server/Server.cs index 483c2e7..f7ead16 100644 --- a/Server/Server.cs +++ b/Server/Server.cs @@ -34,7 +34,7 @@ public class Server { continue; } - Logger.Warn($"Accepted connection for client {socket.RemoteEndPoint}"); + Logger.Notify($"Accepted connection for client {socket.RemoteEndPoint}"); try { #pragma warning disable CS4014 diff --git a/Server/Server.csproj b/Server/Server.csproj index 5710034..166bc50 100644 --- a/Server/Server.csproj +++ b/Server/Server.csproj @@ -12,7 +12,7 @@ - + diff --git a/Server/Settings.cs b/Server/Settings.cs index f696a7d..93a5d7d 100644 --- a/Server/Settings.cs +++ b/Server/Settings.cs @@ -72,10 +72,19 @@ public class Settings { } public class DiscordTable { + public bool Enabled { get; set; } = true; public string? Token { get; set; } public string Prefix { get; set; } = "$"; public string? CommandChannel { get; set; } - public string? LogChannel { get; set; } + //This funkyness is to migrate the JSON "LogChannel" to "AdminChannel" + public string? AdminChannel { get; set; } + [JsonProperty(PropertyName = "LogChannel")] + public string? LogChannel + { + set => AdminChannel = value; + } + public bool LogCommands { get; set; } = false; + public bool FilterOutNonIssueWarnings { get; set; } = true; } public class ShineTable { diff --git a/Shared/Logger.cs b/Shared/Logger.cs index aaab266..33bae18 100644 --- a/Shared/Logger.cs +++ b/Shared/Logger.cs @@ -9,6 +9,8 @@ public class Logger { public string Name { get; set; } + public void Notify(string text) => Handler?.Invoke(Name, "Info", text, ConsoleColor.Green); + public void Info(string text) => Handler?.Invoke(Name, "Info", text, ConsoleColor.White); public void Warn(string text) => Handler?.Invoke(Name, "Warn", text, ConsoleColor.Yellow);