From 9406aaa050c8675b96e34bc22fc4051c00acc733 Mon Sep 17 00:00:00 2001 From: Michael Chen Date: Sun, 16 Jan 2022 15:58:35 +0100 Subject: [PATCH] Finished routing with automatic help text generation Changelog: added --- MinecraftDiscordBot/ChunkWaiter.cs | 43 ++++ MinecraftDiscordBot/CommandRouter.cs | 35 ++- MinecraftDiscordBot/ConnectedComputer.cs | 267 ++++++++++------------- MinecraftDiscordBot/IChunkWaiter.cs | 9 + MinecraftDiscordBot/Program.cs | 26 ++- 5 files changed, 214 insertions(+), 166 deletions(-) create mode 100644 MinecraftDiscordBot/ChunkWaiter.cs create mode 100644 MinecraftDiscordBot/IChunkWaiter.cs diff --git a/MinecraftDiscordBot/ChunkWaiter.cs b/MinecraftDiscordBot/ChunkWaiter.cs new file mode 100644 index 0000000..a422090 --- /dev/null +++ b/MinecraftDiscordBot/ChunkWaiter.cs @@ -0,0 +1,43 @@ +namespace MinecraftDiscordBot; + +public class ChunkWaiter : IChunkWaiter { + public int ID { get; } + private readonly CancellationToken _ct; + public ChunkWaiter(int id, Func resultParser, CancellationToken ct) { + ID = id; + this.resultParser = resultParser; + _ct = ct; + } + private readonly TaskCompletionSource tcs = new(); + private readonly Func resultParser; + public Task Task => tcs.Task.WaitAsync(_ct); + public bool Finished { get; private set; } = false; + 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) { + if (_chunks is null) _chunks = new string[totalChunks]; + else if (_chunks.Length != totalChunks) { + Program.LogErrorAsync(Program.WebSocketSource, new InvalidOperationException("Different numbers of chunks in same message ID!")); + return; + } + ref string? chunk = ref _chunks[chunkId - 1]; // Lua 1-indexed + if (chunk is not null) { + Program.LogErrorAsync(Program.WebSocketSource, new InvalidOperationException($"Chunk with ID {chunkId} was already received!")); + return; + } + chunk = value; + } + if (++_receivedChunks == totalChunks) FinalizeResult(_chunks); + } + private void FinalizeResult(string?[] _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; +} diff --git a/MinecraftDiscordBot/CommandRouter.cs b/MinecraftDiscordBot/CommandRouter.cs index aa656fc..4ca4572 100644 --- a/MinecraftDiscordBot/CommandRouter.cs +++ b/MinecraftDiscordBot/CommandRouter.cs @@ -1,30 +1,47 @@ using Discord; using Discord.WebSocket; using System.Reflection; +using System.Text; namespace MinecraftDiscordBot; -public abstract class CommandRouter : ICommandHandler { - private readonly Dictionary> _handlers = new(); +public abstract class CommandRouter : ICommandHandler { + private readonly Dictionary _handlers = new(); + public abstract string HelpTextPrefix { get; } public CommandRouter() { foreach (var method in GetType().GetMethods()) - if (GetHandlerAttribute(method) is CommandHandlerAttribute handler) + if (GetHandlerAttribute(method) is CommandHandlerAttribute attribute) try { - _handlers.Add(handler.CommandName, method.CreateDelegate>(this)); + _handlers.Add(attribute.CommandName, new(method.CreateDelegate>(this), attribute)); } catch (Exception) { - Program.LogWarning("CommandRouter", $"Could not add delegate for method {handler.CommandName} in function {method.ReturnType} {method.Name}(...)!"); + Program.LogWarning("CommandRouter", $"Could not add delegate for method {attribute.CommandName} in function {method.ReturnType} {method.Name}(...)!"); throw; } } + [CommandHandler("help", HelpText = "Show this help information!")] + public virtual Task GetHelpText(SocketUserMessage message, string[] parameters, CancellationToken ct) + => Task.FromResult(ResponseType.AsString(GenerateHelp())); 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) + 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) + ? handler.Delegate(message, parameters[1..], ct) : FallbackHandler(message, parameters[0], parameters[1..], ct); + private string GenerateHelp() { + var sb = new StringBuilder(); + sb.Append("Command usage:"); + foreach (var (name, handler) in _handlers) { + sb.Append($"\n{HelpTextPrefix}{name}"); + if (handler.Attribute.HelpText is string help) + sb.Append($": {help}"); + } + return sb.ToString(); + } } + +public record struct HandlerStruct(HandleCommandDelegate Delegate, CommandHandlerAttribute Attribute); \ No newline at end of file diff --git a/MinecraftDiscordBot/ConnectedComputer.cs b/MinecraftDiscordBot/ConnectedComputer.cs index dc7c20f..c98a30f 100644 --- a/MinecraftDiscordBot/ConnectedComputer.cs +++ b/MinecraftDiscordBot/ConnectedComputer.cs @@ -15,13 +15,111 @@ public delegate Task HandleCommandDelegate(SocketUserMessage message, string[] p public sealed class CommandHandlerAttribute : Attribute { public CommandHandlerAttribute(string commandName) => CommandName = commandName; public string CommandName { get; } + public string? HelpText { get; init; } } -public class ConnectedComputer : CommandRouter { +public class RefinedStorageService : CommandRouter { + private readonly ITaskWaitSource _taskSource; + public override string HelpTextPrefix => "!rs "; + public RefinedStorageService(ITaskWaitSource taskSource) : base() => _taskSource = taskSource; + public override Task FallbackHandler(SocketUserMessage message, string method, string[] parameters, CancellationToken ct) + => Task.FromResult(ResponseType.AsString($"The RS system has no command '{method}'!")); + public override Task RootAnswer(SocketUserMessage message, CancellationToken ct) + => Task.FromResult(ResponseType.AsString("The RS system is online!")); + + private async Task Method(string methodName, Func parser, CancellationToken ct) { + var waiter = _taskSource.GetWaiter(parser, ct); + await _taskSource.Send(new RequestMessage(waiter.ID, methodName)); + return await waiter.Task; + } + + private const string CmdEnergyUsage = "energyusage"; + private const string CmdEnergyStorage = "energystorage"; + private const string CmdListItems = "listitems"; + private const string CmdItemName = "itemname"; + private const string CmdListFluids = "listfluids"; + + public async Task GetEnergyUsageAsync(CancellationToken ct) => await Method(CmdEnergyUsage, int.Parse, ct); + public async Task GetEnergyStorageAsync(CancellationToken ct) => await Method(CmdEnergyStorage, int.Parse, ct); + public async Task> ListItemsAsync(CancellationToken ct) => await Method(CmdListItems, ConnectedComputer.Deserialize>(), ct); + public async Task> ListFluidsAsync(CancellationToken ct) => await Method(CmdListFluids, ConnectedComputer.Deserialize>(), ct); + + private Task> FilterItems(SocketUserMessage message, IEnumerable filters, CancellationToken ct) + => FilterItems(message, filters.Select(ItemFilter.Parse), ct); + + private async Task> FilterItems(SocketUserMessage message, IEnumerable filters, CancellationToken ct) { + var items = Items?.ToList().AsEnumerable(); + if (items is null) items = (await RefreshItemList(ct)).ToList(); + foreach (var filter in filters) + items = items.Where(filter.MatchItem); + return items.ToList(); + } + + private async Task> RefreshItemList(CancellationToken ct) { + var response = await ListItemsAsync(ct); + lock (_itemLock) { + Items = response.OrderByDescending(i => i.Amount).ToList(); + return Items; + } + } + + private List? Items; + private readonly object _itemLock = new(); + + [CommandHandler(CmdEnergyStorage, HelpText = "Get the amount of energy stored in the RS system.")] + public async Task HandleEnergyStorage(SocketUserMessage message, string[] parameters, CancellationToken ct) + => ResponseType.AsString($"Refined Storage system stores {await GetEnergyStorageAsync(ct)} RF/t"); + [CommandHandler(CmdEnergyUsage, HelpText = "Get the amount of energy used by the RS system.")] + public async Task HandleEnergyUsage(SocketUserMessage message, string[] parameters, CancellationToken ct) + => ResponseType.AsString($"Refined Storage system currently uses {await GetEnergyUsageAsync(ct)} RF/t"); + [CommandHandler(CmdItemName, HelpText = "Filter items by name.")] + 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())); + return ResponseType.AsString(sb.ToString()); + } + } + + [CommandHandler(CmdListFluids, HelpText = "Gets a list of fluids that are currently stored in the RS system.")] + 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); + return ResponseType.AsString(sb.ToString()); + } + + [CommandHandler(CmdListItems, HelpText = "Gets a list of items that are currently stored in the RS system.")] + 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); + lock (_itemLock) { + int taken = 0; + foreach (var item in items) { + if (sb.Length > 500) break; + sb.AppendFormat("\n{0:n0}x {1}", item.Amount, item.DisplayName); + taken++; + } + if (items.Count > taken) sb.AppendFormat("\nand {0} more items.", items.Skip(taken).Sum(i => i.Amount)); + } + return ResponseType.AsString(sb.ToString()); + } +} + +public class ConnectedComputer : CommandRouter, ITaskWaitSource { protected readonly IWebSocketConnection _socket; + public override string HelpTextPrefix => "!"; public ConnectedComputer(IWebSocketConnection socket) : base() { socket.OnMessage = OnMessage; _socket = socket; + _rs = new RefinedStorageService(this); } private void OnMessage(string message) { @@ -39,64 +137,15 @@ public class ConnectedComputer : CommandRouter { lock (_syncRoot) _waits.Remove(waiter.ID); } + public Task Send(string message) => _socket.Send(message); - protected Task Send(Message message) => Send(JsonConvert.SerializeObject(message)); + public Task Send(Message message) => Send(JsonConvert.SerializeObject(message)); private readonly object _syncRoot = new(); private readonly Dictionary _waits = new(); private readonly Random _rnd = new(); public IWebSocketConnectionInfo ConnectionInfo => _socket.ConnectionInfo; - protected interface IChunkWaiter { - bool Finished { get; } - int ID { get; } - bool IsCancellationRequested { get; } - void AddChunk(int chunkId, int totalChunks, string value); - void SetUnsuccessful(); - } - - protected class ChunkWaiter : IChunkWaiter { - public int ID { get; } - private readonly CancellationToken _ct; - public ChunkWaiter(int id, Func resultParser, CancellationToken ct) { - ID = id; - this.resultParser = resultParser; - _ct = ct; - } - private readonly TaskCompletionSource tcs = new(); - private readonly Func resultParser; - public Task Task => tcs.Task.WaitAsync(_ct); - public bool Finished { get; private set; } = false; - 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) { - if (_chunks is null) _chunks = new string[totalChunks]; - else if (_chunks.Length != totalChunks) { - Program.LogErrorAsync(Program.WebSocketSource, new InvalidOperationException("Different numbers of chunks in same message ID!")); - return; - } - ref string? chunk = ref _chunks[chunkId - 1]; // Lua 1-indexed - if (chunk is not null) { - Program.LogErrorAsync(Program.WebSocketSource, new InvalidOperationException($"Chunk with ID {chunkId} was already received!")); - return; - } - chunk = value; - } - if (++_receivedChunks == totalChunks) FinalizeResult(_chunks); - } - private void FinalizeResult(string?[] _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() { + private int GetFreeId() { var attempts = 0; while (true) { var id = _rnd.Next(); @@ -106,7 +155,7 @@ public class ConnectedComputer : CommandRouter { } } - protected ChunkWaiter GetWaiter(Func resultParser, CancellationToken ct) { + public ChunkWaiter GetWaiter(Func resultParser, CancellationToken ct) { ChunkWaiter waiter; lock (_syncRoot) { waiter = new ChunkWaiter(GetFreeId(), resultParser, ct); @@ -115,112 +164,24 @@ public class ConnectedComputer : CommandRouter { return waiter; } - protected static Func Deserialize() => msg - => JsonConvert.DeserializeObject(msg) ?? throw new InvalidProgramException("Empty response!"); - - public const string Role = "rs"; - private const string CmdEnergyUsage = "energyusage"; - private const string CmdEnergyStorage = "energystorage"; - private const string CmdListItems = "listitems"; - private const string CmdItemName = "itemname"; - private const string CmdListFluids = "listfluids"; - - public async Task GetEnergyUsageAsync(CancellationToken ct) { - var waiter = GetWaiter(int.Parse, ct); - await Send(new RequestMessage(waiter.ID, CmdEnergyUsage)); - return await waiter.Task; - } - public async Task GetEnergyStorageAsync(CancellationToken ct) { - var waiter = GetWaiter(int.Parse, ct); - await Send(new RequestMessage(waiter.ID, CmdEnergyStorage)); - return await waiter.Task; - } - public async Task> ListItemsAsync(CancellationToken ct) { - var waiter = GetWaiter(Deserialize>(), ct); - await Send(new RequestMessage(waiter.ID, CmdListItems)); - return await waiter.Task; - } - - public async Task> ListFluidsAsync(CancellationToken ct) { - var waiter = GetWaiter(Deserialize>(), ct); - await Send(new RequestMessage(waiter.ID, CmdListFluids)); - return await waiter.Task; - } - - [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())); - return ResponseType.AsString(sb.ToString()); - } - } - - private Task> FilterItems(SocketUserMessage message, IEnumerable filters, CancellationToken ct) - => FilterItems(message, filters.Select(ItemFilter.Parse), ct); - - private async Task> FilterItems(SocketUserMessage message, IEnumerable filters, CancellationToken ct) { - var items = Items?.ToList().AsEnumerable(); - if (items is null) items = (await RefreshItemList(ct)).ToList(); - foreach (var filter in filters) - items = items.Where(filter.MatchItem); - return items.ToList(); - } - - [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); - return ResponseType.AsString(sb.ToString()); - } - - private List? Items; - private readonly object _itemLock = new(); - - [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); - lock (_itemLock) { - int taken = 0; - foreach (var item in items) { - if (sb.Length > 500) break; - sb.AppendFormat("\n{0:n0}x {1}", item.Amount, item.DisplayName); - taken++; - } - if (items.Count > taken) sb.AppendFormat("\nand {0} more items.", items.Skip(taken).Sum(i => i.Amount)); - } - return ResponseType.AsString(sb.ToString()); - } - - private async Task> RefreshItemList(CancellationToken ct) { - var response = await ListItemsAsync(ct); - lock (_itemLock) { - Items = response.OrderByDescending(i => i.Amount).ToList(); - return Items; - } - } + private readonly ICommandHandler _rs; + [CommandHandler("rs", HelpText ="Provides some commands for interacting with the Refined Storage system.")] + public Task RefinedStorageHandler(SocketUserMessage message, string[] parameters, CancellationToken ct) + => _rs.HandleCommand(message, parameters, ct); + public static Func Deserialize() => msg + => JsonConvert.DeserializeObject(msg) ?? throw new InvalidProgramException("Empty response!"); 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}'?")); } +public interface ITaskWaitSource { + ChunkWaiter GetWaiter(Func resultParser, CancellationToken ct); + Task Send(Message requestMessage); +} + [Serializable] public class ReplyException : Exception { public ReplyException() { } diff --git a/MinecraftDiscordBot/IChunkWaiter.cs b/MinecraftDiscordBot/IChunkWaiter.cs new file mode 100644 index 0000000..027402a --- /dev/null +++ b/MinecraftDiscordBot/IChunkWaiter.cs @@ -0,0 +1,9 @@ +namespace MinecraftDiscordBot; + +public interface IChunkWaiter { + bool Finished { get; } + int ID { get; } + bool IsCancellationRequested { get; } + void AddChunk(int chunkId, int totalChunks, string value); + void SetUnsuccessful(); +} diff --git a/MinecraftDiscordBot/Program.cs b/MinecraftDiscordBot/Program.cs index 502d017..c186ae5 100644 --- a/MinecraftDiscordBot/Program.cs +++ b/MinecraftDiscordBot/Program.cs @@ -16,6 +16,7 @@ public class Program : IDisposable, ICommandHandler { public const string WebSocketSource = "WebSocket"; public const string BotSource = "Bot"; private static readonly object LogLock = new(); + public const int ChoiceTimeout = 20 * 1000; private readonly DiscordSocketClient _client = new(new() { LogLevel = LogSeverity.Verbose, GatewayIntents = GatewayIntents.AllUnprivileged & ~(GatewayIntents.GuildScheduledEvents | GatewayIntents.GuildInvites) @@ -47,6 +48,7 @@ public class Program : IDisposable, ICommandHandler { _config = config; _client.Log += LogAsync; _client.MessageReceived += (msg) => DiscordMessageReceived(msg); + _client.ReactionAdded += DiscordReactionAdded; _wssv = new WebSocketServer($"ws://0.0.0.0:{config.Port}") { RestartAfterListenError = true }; @@ -157,20 +159,36 @@ public class Program : IDisposable, ICommandHandler { // TODO: Relay Message to Chat Receiver } - private static Task SendResponse(SocketUserMessage message, ResponseType response) => response switch { + private 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 static async Task HandleChoice(SocketUserMessage message, ResponseType.IChoiceResponse res) { + private readonly ConcurrentDictionary _choiceWait = new(); + + private async Task DiscordReactionAdded(Cacheable message, Cacheable channel, SocketReaction reaction) { + var msgObject = await message.GetOrDownloadAsync(); + if (reaction.UserId == _client.CurrentUser.Id) return; + if (!_choiceWait.TryRemove(message.Id, out var choice)) { await LogInfoAsync(BotSource, "Reaction was added to message without choice object!"); return; } + await msgObject.DeleteAsync(); + await LogInfoAsync(BotSource, $"Reaction {reaction.Emote.Name} was added to the choice by {reaction.UserId}!"); + } + + private 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️⃣") }; + _choiceWait[reply.Id] = res; + 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); + _ = Task.Run(async () => { + await Task.Delay(ChoiceTimeout); + _ = _choiceWait.TryRemove(message.Id, out _); + await reply.ModifyAsync(i => i.Content = "You did not choose in time!"); + await reply.RemoveAllReactionsAsync(); + }); } 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);