From ede4efa4e331e6172a2e49b177c3e73e907607c3 Mon Sep 17 00:00:00 2001 From: Michael Chen Date: Sat, 15 Jan 2022 21:26:32 +0100 Subject: [PATCH] Added command routing Added router class using attributes Added success parameter to mc computer response Added generic answer type class (for future choice results) Changelog: added --- MinecraftDiscordBot/CommandRouter.cs | 30 +++++ MinecraftDiscordBot/ConnectedComputer.cs | 124 +++++++----------- MinecraftDiscordBot/ICommandHandler.cs | 11 ++ MinecraftDiscordBot/ItemFilter.cs | 32 +++++ MinecraftDiscordBot/Message.cs | 15 ++- .../MinecraftDiscordBot.csproj | 1 + MinecraftDiscordBot/Program.cs | 117 +++++++++++------ 7 files changed, 210 insertions(+), 120 deletions(-) create mode 100644 MinecraftDiscordBot/CommandRouter.cs create mode 100644 MinecraftDiscordBot/ICommandHandler.cs create mode 100644 MinecraftDiscordBot/ItemFilter.cs diff --git a/MinecraftDiscordBot/CommandRouter.cs b/MinecraftDiscordBot/CommandRouter.cs new file mode 100644 index 0000000..aa656fc --- /dev/null +++ b/MinecraftDiscordBot/CommandRouter.cs @@ -0,0 +1,30 @@ +using Discord; +using Discord.WebSocket; +using System.Reflection; + +namespace MinecraftDiscordBot; + +public abstract class CommandRouter : ICommandHandler { + private readonly Dictionary> _handlers = new(); + public CommandRouter() { + foreach (var method in GetType().GetMethods()) + if (GetHandlerAttribute(method) is CommandHandlerAttribute handler) + try { + _handlers.Add(handler.CommandName, method.CreateDelegate>(this)); + } catch (Exception) { + Program.LogWarning("CommandRouter", $"Could not add delegate for method {handler.CommandName} in function {method.ReturnType} {method.Name}(...)!"); + throw; + } + } + + private static CommandHandlerAttribute? GetHandlerAttribute(MethodInfo method) + => method.GetCustomAttributes(typeof(CommandHandlerAttribute), true).OfType().FirstOrDefault(); + public abstract Task RootAnswer(SocketUserMessage message, CancellationToken ct); + public abstract Task FallbackHandler(SocketUserMessage message, string method, string[] parameters, CancellationToken ct); + public Task HandleCommand(SocketUserMessage message, string[] parameters, CancellationToken ct) + => parameters is { Length: 0 } + ? RootAnswer(message, ct) + : _handlers.TryGetValue(parameters[0], out var handler) + ? handler(message, parameters[1..], ct) + : FallbackHandler(message, parameters[0], parameters[1..], ct); +} diff --git a/MinecraftDiscordBot/ConnectedComputer.cs b/MinecraftDiscordBot/ConnectedComputer.cs index d99d228..dc7c20f 100644 --- a/MinecraftDiscordBot/ConnectedComputer.cs +++ b/MinecraftDiscordBot/ConnectedComputer.cs @@ -3,13 +3,23 @@ using Discord.WebSocket; using Fleck; using Newtonsoft.Json; using System.Diagnostics; +using System.Runtime.Serialization; using System.Text; namespace MinecraftDiscordBot; -public class ConnectedComputer { +public delegate Task HandleCommandDelegate(SocketUserMessage message, string[] parameters, CancellationToken ct); +public delegate Task HandleCommandDelegate(SocketUserMessage message, string[] parameters, CancellationToken ct); + +[AttributeUsage(AttributeTargets.Method, Inherited = true, AllowMultiple = false)] +public sealed class CommandHandlerAttribute : Attribute { + public CommandHandlerAttribute(string commandName) => CommandName = commandName; + public string CommandName { get; } +} + +public class ConnectedComputer : CommandRouter { protected readonly IWebSocketConnection _socket; - public ConnectedComputer(IWebSocketConnection socket) { + public ConnectedComputer(IWebSocketConnection socket) : base() { socket.OnMessage = OnMessage; _socket = socket; } @@ -23,6 +33,7 @@ public class ConnectedComputer { return; } } + if (!msg.Success) waiter.SetUnsuccessful(); waiter.AddChunk(msg.Chunk, msg.Total, msg.Result); if (waiter.Finished || waiter.IsCancellationRequested) lock (_syncRoot) @@ -40,6 +51,7 @@ public class ConnectedComputer { int ID { get; } bool IsCancellationRequested { get; } void AddChunk(int chunkId, int totalChunks, string value); + void SetUnsuccessful(); } protected class ChunkWaiter : IChunkWaiter { @@ -57,6 +69,7 @@ public class ConnectedComputer { public bool IsCancellationRequested => _ct.IsCancellationRequested; private string?[]? _chunks = null; private int _receivedChunks = 0; + private bool _success = true; private readonly object _syncRoot = new(); public void AddChunk(int chunkId, int totalChunks, string value) { lock (_syncRoot) { @@ -75,9 +88,12 @@ public class ConnectedComputer { if (++_receivedChunks == totalChunks) FinalizeResult(_chunks); } private void FinalizeResult(string?[] _chunks) { - tcs.SetResult(resultParser(string.Concat(_chunks))); + var resultString = string.Concat(_chunks); + if (_success) tcs.SetResult(resultParser(resultString)); + else tcs.SetException(new ReplyException(resultString)); Finished = true; } + public void SetUnsuccessful() => _success = false; } protected int GetFreeId() { @@ -101,9 +117,7 @@ public class ConnectedComputer { protected static Func Deserialize() => msg => JsonConvert.DeserializeObject(msg) ?? throw new InvalidProgramException("Empty response!"); -} -public class RefinedStorageComputer : ConnectedComputer { public const string Role = "rs"; private const string CmdEnergyUsage = "energyusage"; private const string CmdEnergyStorage = "energystorage"; @@ -111,7 +125,6 @@ public class RefinedStorageComputer : ConnectedComputer { private const string CmdItemName = "itemname"; private const string CmdListFluids = "listfluids"; - public RefinedStorageComputer(IWebSocketConnection socket) : base(socket) { } public async Task GetEnergyUsageAsync(CancellationToken ct) { var waiter = GetWaiter(int.Parse, ct); await Send(new RequestMessage(waiter.ID, CmdEnergyUsage)); @@ -134,46 +147,21 @@ public class RefinedStorageComputer : ConnectedComputer { return await waiter.Task; } - public async Task HandleCommand(SocketUserMessage message, string[] parameters, CancellationToken ct) { - if (parameters is not { Length: > 0 }) { - await message.ReplyAsync($"Refined Storage system is online"); - return; - } - - try { - switch (parameters[0].ToLower()) { - case CmdEnergyUsage: - await message.ReplyAsync($"Refined Storage system currently uses {await GetEnergyUsageAsync(ct)} RF/t"); - break; - case CmdEnergyStorage: - await message.ReplyAsync($"Refined Storage system stores {await GetEnergyStorageAsync(ct)} RF/t"); - break; - case CmdListItems: - await HandleItemListing(message, ct); - break; - case CmdItemName: - await HandleItemName(message, parameters, ct); - break; - case CmdListFluids: - await HandleFluidListing(message, ct); - break; - case string other: - await message.ReplyAsync($"Refined Storages cannot do '{other}', bruh"); - break; - } - } catch (TaskCanceledException) { - await message.ReplyAsync("The Refined Storage system request timed out!"); - } - } - - private async Task HandleItemName(SocketUserMessage message, string[] parameters, CancellationToken ct) { - if (parameters.Length < 2) await message.ReplyAsync($"Usage: {CmdItemName} filters..."); + [CommandHandler(CmdEnergyStorage)] + public async Task HandleEnergyStorage(SocketUserMessage message, string[] parameters, CancellationToken ct) + => ResponseType.AsString($"Refined Storage system stores {await GetEnergyStorageAsync(ct)} RF/t"); + [CommandHandler(CmdEnergyUsage)] + public async Task HandleEnergyUsage(SocketUserMessage message, string[] parameters, CancellationToken ct) + => ResponseType.AsString($"Refined Storage system currently uses {await GetEnergyUsageAsync(ct)} RF/t"); + [CommandHandler(CmdItemName)] + public async Task HandleItemName(SocketUserMessage message, string[] parameters, CancellationToken ct) { + if (parameters.Length < 2) return ResponseType.AsString($"Usage: {CmdItemName} filters..."); else { var items = await FilterItems(message, parameters[1..], ct); var sb = new StringBuilder(); sb.AppendLine("Did you mean:"); sb.AppendJoin("\n", items.Select(i => i.ToString())); - await message.ReplyAsync(sb.ToString()); + return ResponseType.AsString(sb.ToString()); } } @@ -188,51 +176,22 @@ public class RefinedStorageComputer : ConnectedComputer { return items.ToList(); } - public abstract class ItemFilter { - public abstract bool Match(Fluid item); - public virtual bool MatchItem(Item item) => Match(item); - - public static ItemFilter Parse(string filter) - => filter.StartsWith('@') - ? new ModNameFilter(filter[1..]) - : filter.StartsWith('$') - ? new TagFilter(filter[1..]) - : new ItemNameFilter(filter); - - private class ModNameFilter : ItemFilter { - private readonly string filter; - public ModNameFilter(string filter) => this.filter = filter; - public override bool Match(Fluid item) => item.ItemId.ModName.Contains(filter, StringComparison.InvariantCultureIgnoreCase); - } - - private class TagFilter : ItemFilter { - private readonly string filter; - public TagFilter(string filter) => this.filter = filter; - public override bool Match(Fluid item) - => item.Tags?.Any(tag => tag.Contains(filter, StringComparison.InvariantCultureIgnoreCase)) ?? false; - } - - private class ItemNameFilter : ItemFilter { - private readonly string filter; - public ItemNameFilter(string filter) => this.filter = filter; - public override bool Match(Fluid item) => item.DisplayName.Contains(filter, StringComparison.InvariantCultureIgnoreCase); - } - } - - private async Task HandleFluidListing(SocketUserMessage message, CancellationToken ct) { + [CommandHandler(CmdListFluids)] + public async Task HandleFluidListing(SocketUserMessage message, string[] parameters, CancellationToken ct) { var sb = new StringBuilder(); sb.Append("The Refined Storage system stores those fluids:"); var fluids = await ListFluidsAsync(ct); foreach (var fluid in fluids.OrderByDescending(i => i.Amount)) if (fluid.Amount > 10000) sb.AppendFormat("\n{0:n2} B of {1}", fluid.Amount / 1000.0f, fluid.DisplayName); else sb.AppendFormat("\n{0:n0} mB of {1}", fluid.Amount, fluid.DisplayName); - await message.ReplyAsync(sb.ToString()); + return ResponseType.AsString(sb.ToString()); } private List? Items; private readonly object _itemLock = new(); - private async Task HandleItemListing(SocketUserMessage message, CancellationToken ct) { + [CommandHandler(CmdListItems)] + public async Task HandleItemListing(SocketUserMessage message, string[] parameters, CancellationToken ct) { var sb = new StringBuilder(); sb.Append("The Refined Storage system currently stores these items:"); var items = await RefreshItemList(ct); @@ -245,7 +204,7 @@ public class RefinedStorageComputer : ConnectedComputer { } if (items.Count > taken) sb.AppendFormat("\nand {0} more items.", items.Skip(taken).Sum(i => i.Amount)); } - await message.ReplyAsync(sb.ToString()); + return ResponseType.AsString(sb.ToString()); } private async Task> RefreshItemList(CancellationToken ct) { @@ -255,6 +214,19 @@ public class RefinedStorageComputer : ConnectedComputer { return Items; } } + + public override Task RootAnswer(SocketUserMessage message, CancellationToken ct) + => Task.FromResult(ResponseType.AsString("The Minecraft server is connected!")); + public override Task FallbackHandler(SocketUserMessage message, string method, string[] parameters, CancellationToken ct) + => Task.FromResult(ResponseType.AsString($"What the fuck do you mean by '{method}'?")); +} + +[Serializable] +public class ReplyException : Exception { + public ReplyException() { } + public ReplyException(string message) : base(message) { } + public ReplyException(string message, Exception inner) : base(message, inner) { } + protected ReplyException(SerializationInfo info, StreamingContext context) : base(info, context) { } } [JsonObject(MemberSerialization.OptIn, Description = "Describes an item in a Refined Storage system.", MissingMemberHandling = MissingMemberHandling.Ignore)] diff --git a/MinecraftDiscordBot/ICommandHandler.cs b/MinecraftDiscordBot/ICommandHandler.cs new file mode 100644 index 0000000..3ffffd0 --- /dev/null +++ b/MinecraftDiscordBot/ICommandHandler.cs @@ -0,0 +1,11 @@ +using Discord.WebSocket; + +namespace MinecraftDiscordBot; + +public interface ICommandHandler { + Task HandleCommand(SocketUserMessage message, string[] parameters, CancellationToken ct); +} + +public interface ICommandHandler { + Task HandleCommand(SocketUserMessage message, string[] parameters, CancellationToken ct); +} \ No newline at end of file diff --git a/MinecraftDiscordBot/ItemFilter.cs b/MinecraftDiscordBot/ItemFilter.cs new file mode 100644 index 0000000..f229a01 --- /dev/null +++ b/MinecraftDiscordBot/ItemFilter.cs @@ -0,0 +1,32 @@ +namespace MinecraftDiscordBot; + +public abstract class ItemFilter { + public abstract bool Match(Fluid item); + public virtual bool MatchItem(Item item) => Match(item); + + public static ItemFilter Parse(string filter) + => filter.StartsWith('@') + ? new ModNameFilter(filter[1..]) + : filter.StartsWith('$') + ? new TagFilter(filter[1..]) + : new ItemNameFilter(filter); + + private class ModNameFilter : ItemFilter { + private readonly string filter; + public ModNameFilter(string filter) => this.filter = filter; + public override bool Match(Fluid item) => item.ItemId.ModName.Contains(filter, StringComparison.InvariantCultureIgnoreCase); + } + + private class TagFilter : ItemFilter { + private readonly string filter; + public TagFilter(string filter) => this.filter = filter; + public override bool Match(Fluid item) + => item.Tags?.Any(tag => tag.Contains(filter, StringComparison.InvariantCultureIgnoreCase)) ?? false; + } + + private class ItemNameFilter : ItemFilter { + private readonly string filter; + public ItemNameFilter(string filter) => this.filter = filter; + public override bool Match(Fluid item) => item.DisplayName.Contains(filter, StringComparison.InvariantCultureIgnoreCase); + } +} diff --git a/MinecraftDiscordBot/Message.cs b/MinecraftDiscordBot/Message.cs index 4a6dcab..d6ab349 100644 --- a/MinecraftDiscordBot/Message.cs +++ b/MinecraftDiscordBot/Message.cs @@ -11,7 +11,7 @@ public abstract class Message { public class CapabilityMessage : Message { public override string Type => "roles"; [JsonProperty("role", Required = Required.Always)] - public string Role { get; set; } = default!; + public string[] Role { get; set; } = default!; } public class TextMessage : Message { @@ -36,10 +36,15 @@ public class ReplyMessage : Message { public int AnswerId { get; set; } [JsonProperty("result", Required = Required.Always)] public string Result { get; set; } - [JsonProperty("chunk", Required = Required.Always)] - public int Chunk { get; set; } - [JsonProperty("total", Required = Required.Always)] - public int Total { get; set; } + [JsonProperty("chunk", Required = Required.DisallowNull)] + public int Chunk { get; set; } = 1; + [JsonProperty("total", Required = Required.DisallowNull)] + public int Total { get; set; } = 1; + /// + /// If at least one packet was received where + /// + [JsonProperty("success", Required = Required.DisallowNull)] + public bool Success { get; set; } = true; public override string Type => "reply"; } diff --git a/MinecraftDiscordBot/MinecraftDiscordBot.csproj b/MinecraftDiscordBot/MinecraftDiscordBot.csproj index df63193..810ea2d 100644 --- a/MinecraftDiscordBot/MinecraftDiscordBot.csproj +++ b/MinecraftDiscordBot/MinecraftDiscordBot.csproj @@ -21,6 +21,7 @@ + diff --git a/MinecraftDiscordBot/Program.cs b/MinecraftDiscordBot/Program.cs index bf98a91..502d017 100644 --- a/MinecraftDiscordBot/Program.cs +++ b/MinecraftDiscordBot/Program.cs @@ -1,17 +1,18 @@ -using CommandLine; +using CommandLine; using Discord; using Discord.Commands; using Discord.Rest; using Discord.WebSocket; using Fleck; using Newtonsoft.Json; +using OneOf; using System.Collections.Concurrent; using System.Reflection; using System.Runtime.CompilerServices; namespace MinecraftDiscordBot; -public class Program : IDisposable { +public class Program : IDisposable, ICommandHandler { public const string WebSocketSource = "WebSocket"; public const string BotSource = "Bot"; private static readonly object LogLock = new(); @@ -25,16 +26,18 @@ public class Program : IDisposable { private readonly ConcurrentDictionary _connections = new(); private static readonly char[] WhiteSpace = new char[] { '\t', '\n', ' ', '\r' }; public ITextChannel[] _channels = Array.Empty(); - private RefinedStorageComputer? _rsSystem = null; + private ConnectedComputer? _rsSystem = null; private bool disposedValue; + public static bool OnlineNotifications => false; - public RefinedStorageComputer? RsSystem { + public ConnectedComputer? Computer { get => _rsSystem; set { if (_rsSystem != value) { _rsSystem = value; - _ = Task.Run(() => Broadcast(i => i.SendMessageAsync(value is null - ? $"The Refined Storage went offline. Please check the server!" - : $"The Refined Storage is back online!"))); + if (OnlineNotifications) + _ = Task.Run(() => Broadcast(i => i.SendMessageAsync(value is null + ? $"The Refined Storage went offline. Please check the server!" + : $"The Refined Storage is back online!"))); } } } @@ -115,27 +118,13 @@ public class Program : IDisposable { } } - private async Task SocketReceived(IWebSocketConnection socket, string message) { - if (JsonConvert.DeserializeObject(message) is not CapabilityMessage capability) return; + private static async Task SocketReceived(IWebSocketConnection socket, string message) + => await LogInfoAsync(WebSocketSource, $"[{socket.ConnectionInfo.Id}] Received: {message}"); - try { - var pc = capability.Role switch { - RefinedStorageComputer.Role => new RefinedStorageComputer(socket), - string role => throw new ArgumentException($"Invalid role '{role}'!") - }; - AddComputerSocket(socket, pc); - await LogInfoAsync(WebSocketSource, $"[{socket.ConnectionInfo.Id}] Presented capability as {pc.GetType().Name}"); - } catch (ArgumentException e) { - await LogErrorAsync(WebSocketSource, e); - } - } - - private void AddComputerSocket(IWebSocketConnection socket, ConnectedComputer pc) { - if (pc is RefinedStorageComputer rs) RsSystem = rs; - } + private void AddComputerSocket(IWebSocketConnection socket, ConnectedComputer pc) => Computer = pc; private void RemoveComputerSocket(IWebSocketConnection socket) { - if (RsSystem?.ConnectionInfo.Id == socket.ConnectionInfo.Id) RsSystem = null; + if (Computer is { ConnectionInfo.Id: Guid id } && id == socket.ConnectionInfo.Id) Computer = null; } private async Task SocketClosed(IWebSocketConnection socket) { @@ -143,8 +132,10 @@ public class Program : IDisposable { await LogInfoAsync(WebSocketSource, $"[{socket.ConnectionInfo.Id}] Client disconnected!"); } - private static async Task SocketOpened(IWebSocketConnection socket) - => await LogInfoAsync(WebSocketSource, $"[{socket.ConnectionInfo.Id}] Client connected from {socket.ConnectionInfo.ClientIpAddress}:{socket.ConnectionInfo.ClientPort}!"); + private async Task SocketOpened(IWebSocketConnection socket) { + AddComputerSocket(socket, new(socket)); + await LogInfoAsync(WebSocketSource, $"[{socket.ConnectionInfo.Id}] Client connected from {socket.ConnectionInfo.ClientIpAddress}:{socket.ConnectionInfo.ClientPort}!"); + } private async Task DiscordMessageReceived(SocketMessage arg, int timeout = 10000) { if (arg is not SocketUserMessage message) return; @@ -155,7 +146,10 @@ public class Program : IDisposable { if (IsCommand(message, out var argPos)) { var parameters = message.Content[argPos..].Split(WhiteSpace, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - _ = Task.Run(() => HandleCommand(message, parameters, cts.Token)); + _ = Task.Run(async () => { + var response = await HandleCommand(message, parameters, cts.Token); + await SendResponse(message, response); + }); return; } @@ -163,18 +157,34 @@ public class Program : IDisposable { // TODO: Relay Message to Chat Receiver } - private Task HandleCommand(SocketUserMessage message, string[] parameters, CancellationToken ct) - => parameters is { Length: > 0 } - ? parameters[0].ToLower() switch { - RefinedStorageComputer.Role => HandleRefinedStorageCommand(message, parameters[1..], ct), - _ => message.ReplyAsync($"What the fuck do you mean by '{parameters[0]}'?") - } - : message.ReplyAsync($"You really think an empty command works?"); + private static Task SendResponse(SocketUserMessage message, ResponseType response) => response switch { + ResponseType.IChoiceResponse res => HandleChoice(message, res), + ResponseType.StringResponse res => message.ReplyAsync(res.Message), + _ => message.ReplyAsync($"Whoops, someone forgot to implement '{response.GetType()}' responses?"), + }; - private Task HandleRefinedStorageCommand(SocketUserMessage message, string[] parameters, CancellationToken ct) - => RsSystem is RefinedStorageComputer rs - ? rs.HandleCommand(message, parameters, ct) - : message.ReplyAsync("The Refined Storage system is currently unavailable!"); + private static async Task HandleChoice(SocketUserMessage message, ResponseType.IChoiceResponse res) { + var reply = await message.ReplyAsync($"{res.Query}\n{string.Join("\n", res.Options)}"); + var reactions = new Emoji[] { new("0️⃣"), new("1️⃣"), new("2️⃣"), new("3️⃣"), new("4️⃣"), new("5️⃣"), new("6️⃣"), new("7️⃣"), new("8️⃣"), new("9️⃣") }; + await reply.AddReactionsAsync(reactions); + } + + public async Task HandleCommand(SocketUserMessage message, string[] parameters, CancellationToken ct) { + return ResponseType.FromChoice("Select an emoji:", new[] { "One", "Two", "Nine", "420" }, (choice) => message.ReplyAsync($"You chose: {choice}")); + if (Computer is ICommandHandler handler) + try { + return await handler.HandleCommand(message, parameters, ct); + } catch (TaskCanceledException) { + return ResponseType.AsString("Your request could not be processed in time!"); + } catch (ReplyException e) { + await LogWarningAsync(BotSource, e.Message); + return ResponseType.AsString($"Your request failed: {e.Message}"); + } catch (Exception e) { + await LogErrorAsync(BotSource, e); + return ResponseType.AsString($"Oopsie doopsie, this should not have happened!"); + } + else return ResponseType.AsString("The Minecraft server is currently unavailable!"); + } private bool IsCommand(SocketUserMessage message, out int argPos) { argPos = 0; @@ -227,3 +237,32 @@ public class Program : IDisposable { GC.SuppressFinalize(this); } } + +public abstract class ResponseType { + private static string DefaultDisplay(T obj) => obj?.ToString() ?? throw new InvalidProgramException("ToString did not yield anything!"); + public static ResponseType AsString(string message) => new StringResponse(message); + public static ResponseType FromChoice(string query, IEnumerable choice, Func resultHandler, Func? display = null) => new ChoiceResponse(query, choice, resultHandler, display ?? DefaultDisplay); + public class StringResponse : ResponseType { + public StringResponse(string message) => Message = message; + public string Message { get; } + } + public interface IChoiceResponse { + IEnumerable Options { get; } + string Query { get; } + Task HandleResult(int index); + } + public class ChoiceResponse : ResponseType, IChoiceResponse { + private readonly Func _resultHandler; + private readonly T[] _options; + private readonly Func _displayer; + public IEnumerable Options => _options.Select(_displayer); + public string Query { get; } + public Task HandleResult(int index) => _resultHandler(_options[index]); + public ChoiceResponse(string query, IEnumerable choice, Func resultHandler, Func display) { + Query = query; + _resultHandler = resultHandler; + _options = choice.ToArray(); + _displayer = display; + } + } +} \ No newline at end of file