diff --git a/MinecraftDiscordBot/BotConfiguration.cs b/MinecraftDiscordBot/BotConfiguration.cs index 0922222..34f7f21 100644 --- a/MinecraftDiscordBot/BotConfiguration.cs +++ b/MinecraftDiscordBot/BotConfiguration.cs @@ -21,8 +21,11 @@ public class BotConfiguration : IBotConfiguration, IBotConfigurator { [Option("prefix", Default = DEFAULT_PREFIX, HelpText = "The Discord bot command prefix")] public string Prefix { get; init; } = DEFAULT_PREFIX; [JsonProperty("host", Required = Required.Always)] - [Option("host", Default = DEFAULT_PREFIX, HelpText = "The Discord bot command prefix", Required = true)] + [Option("host", Default = DEFAULT_PREFIX, HelpText = "The external websocket hostname.", Required = true)] public string SocketHost { get; init; } = default!; + [JsonProperty("admins", Required = Required.DisallowNull)] + [Option("admins", Default = new ulong[] { }, HelpText = "The list of bot administrators.")] + public ulong[] Administrators { get; init; } = Array.Empty(); [JsonIgnore] public BotConfiguration Config => this; } diff --git a/MinecraftDiscordBot/ChunkWaiter.cs b/MinecraftDiscordBot/ChunkWaiter.cs index 184ce33..9348daa 100644 --- a/MinecraftDiscordBot/ChunkWaiter.cs +++ b/MinecraftDiscordBot/ChunkWaiter.cs @@ -1,4 +1,5 @@ -using MinecraftDiscordBot.Services; +using MinecraftDiscordBot.Models; +using MinecraftDiscordBot.Services; namespace MinecraftDiscordBot; @@ -17,7 +18,7 @@ public class ChunkWaiter : IChunkWaiter { public bool IsCancellationRequested => _ct.IsCancellationRequested; private string?[]? _chunks = null; private int _receivedChunks = 0; - private bool _success = true; + private ResultState? _state = null; private readonly object _syncRoot = new(); public void AddChunk(int chunkId, int totalChunks, string value) { lock (_syncRoot) { @@ -37,9 +38,15 @@ public class ChunkWaiter : IChunkWaiter { } private void FinalizeResult(string?[] _chunks) { var resultString = string.Concat(_chunks); - if (_success) tcs.SetResult(resultParser(resultString)); - else tcs.SetException(new ReplyException(resultString)); + switch (_state) { + case ResultState.Successful: tcs.SetResult(resultParser(resultString)); break; + case ResultState.Unsuccessful: tcs.SetException(new ReplyException(resultString)); break; + case ResultState.Fatal: tcs.SetException(new InvalidProgramException($"Client script failed: {resultString}")); break; + default: throw new InvalidProgramException($"Program cannot handle result state '{_state}'!"); + } Finished = true; } - public void SetUnsuccessful() => _success = false; + public void SetResultState(ResultState state) => _state = _state is ResultState oldState && state != oldState + ? throw new InvalidOperationException("Cannot set two different result states for same message!") + : state; } diff --git a/MinecraftDiscordBot/ClientScript.lua b/MinecraftDiscordBot/ClientScript.lua index 18408a6..453edd7 100644 --- a/MinecraftDiscordBot/ClientScript.lua +++ b/MinecraftDiscordBot/ClientScript.lua @@ -7,10 +7,15 @@ local function chunkString(value, chunkSize) local length = value:len() local total = math.ceil(length / chunkSize) local chunks = {} - local i = 1 - for i=1,total do - local pos = 1 + ((i - 1) * chunkSize) - chunks[i] = value:sub(pos, pos + chunkSize - 1) + if length == 0 then + total = 1 + chunks[1] = "" + else + local i = 1 + for i=1,total do + local pos = 1 + ((i - 1) * chunkSize) + chunks[i] = value:sub(pos, pos + chunkSize - 1) + end end return total, chunks end @@ -20,13 +25,8 @@ local function sendJson(socket, message) end local function sendResponse(socket, id, result, success) - if success == nil then success = true end + if success == nil then success = 0 end - if not success then - sendJson(socket, { id = id, result = result, success = success }) - return - end - local total, chunks = chunkString(result) for i, chunk in pairs(chunks) do sendJson(socket, { id = id, result = chunk, chunk = i, total = total, success = success }) @@ -37,10 +37,23 @@ end -- return rssystem rs local function getPeripheral(name) local dev = peripheral.find(name) - if not dev then error("No peripheral '"..name.."' attached to the computer!") end + if not dev then error({message = "No peripheral '"..name.."' attached to the computer!"}) end return dev end +local function runRsCommand(params) + local script, reason = loadstring("local rs = peripheral.find(\"rsBridge\") if not rs then error({message = \"RS Bridge is not attached!\"}) end return rs."..params.command) + if not script then error({message = "Invalid command: "..reason.."!"}) end + local result = table.pack(pcall(script)) + local success = result[1] + if not success then error({message = "Command execution failed: "..result[2].."!"}) end + + local retvals = {} + retvals.n = result.n - 1 + for i=1,retvals.n do retvals[tostring(i)] = result[i + 1] end + return textutils.serializeJSON(retvals) +end + -- error: any error during execution -- return string result local function getResponse(parsed) @@ -55,10 +68,14 @@ local function getResponse(parsed) elseif parsed.method == "craft" then return tostring(getPeripheral("rsBridge").craftItem(parsed.params)) elseif parsed.method == "getitem" then - return textutils.serializeJSON(getPeripheral("rsBridge").getItem(parsed.params)) + local item = getPeripheral("rsBridge").getItem(parsed.params) + if not item then error({message = "Requested item not found!"}) end + return textutils.serializeJSON(item) + elseif parsed.method == "command" then + return runRsCommand(parsed.params) end - error("No message handler for method: "..parsed.method.."!") + error({message = "No message handler for method: "..parsed.method.."!"}) end local function logJSON(json, prefix) @@ -86,7 +103,15 @@ local function handleMessage(socket, message) if parsed.type == "request" then local success, result = pcall(function() return getResponse(parsed) end) - sendResponse(socket, parsed.id, result, success) + if not success then + if not result.message then + sendResponse(socket, parsed.id, result, 2) + else + sendResponse(socket, parsed.id, result.message, 1) + end + else + sendResponse(socket, parsed.id, result, 0) + end return true end diff --git a/MinecraftDiscordBot/Commands/CommandRouter.cs b/MinecraftDiscordBot/Commands/CommandRouter.cs index 858b6a7..623017f 100644 --- a/MinecraftDiscordBot/Commands/CommandRouter.cs +++ b/MinecraftDiscordBot/Commands/CommandRouter.cs @@ -25,7 +25,7 @@ public abstract class CommandRouter : ICommandHandler { => 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 virtual Task RootAnswer(SocketUserMessage message, CancellationToken ct) => GetHelpText(message, Array.Empty(), 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 } diff --git a/MinecraftDiscordBot/IChunkWaiter.cs b/MinecraftDiscordBot/IChunkWaiter.cs index 027402a..8bb50ae 100644 --- a/MinecraftDiscordBot/IChunkWaiter.cs +++ b/MinecraftDiscordBot/IChunkWaiter.cs @@ -1,9 +1,11 @@ -namespace MinecraftDiscordBot; +using MinecraftDiscordBot.Models; + +namespace MinecraftDiscordBot; public interface IChunkWaiter { bool Finished { get; } int ID { get; } bool IsCancellationRequested { get; } void AddChunk(int chunkId, int totalChunks, string value); - void SetUnsuccessful(); + void SetResultState(ResultState state); } diff --git a/MinecraftDiscordBot/IUserRoleManager.cs b/MinecraftDiscordBot/IUserRoleManager.cs new file mode 100644 index 0000000..5850673 --- /dev/null +++ b/MinecraftDiscordBot/IUserRoleManager.cs @@ -0,0 +1,13 @@ +using Discord.WebSocket; + +namespace MinecraftDiscordBot; + +public interface IUserRoleManager { + /// + /// Verifies that a user is a bot administrator. + /// + /// User ID. + /// An optional message to throw when user is not authorized. + /// User is not authorized. + void RequireAdministrator(ulong user, string? message = null); +} \ No newline at end of file diff --git a/MinecraftDiscordBot/MinecraftDiscordBot.csproj b/MinecraftDiscordBot/MinecraftDiscordBot.csproj index 61c75c0..21f27df 100644 --- a/MinecraftDiscordBot/MinecraftDiscordBot.csproj +++ b/MinecraftDiscordBot/MinecraftDiscordBot.csproj @@ -6,7 +6,7 @@ enable enable Linux - 1.1.0 + 1.1.1 Michael Chen $(Authors) https://gitlab.com/chenmichael/mcdiscordbot diff --git a/MinecraftDiscordBot/Models/LuaPackedArray.cs b/MinecraftDiscordBot/Models/LuaPackedArray.cs new file mode 100644 index 0000000..4e947fc --- /dev/null +++ b/MinecraftDiscordBot/Models/LuaPackedArray.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json; + +namespace MinecraftDiscordBot.Models; + +public class LuaPackedArray { + public ref object? this[int i] => ref _items[i]; + private readonly object?[] _items; + public LuaPackedArray(IDictionary packedTable) { + if (packedTable["n"] is not long n) throw new ArgumentException("No length in packed array!"); + _items = new object?[n]; + for (var i = 0; i < _items.Length; i++) + _items[i] = packedTable.TryGetValue((i + 1).ToString(), out var val) ? val : null; + } + public static LuaPackedArray Deserialize(string value) { + var dict = JsonConvert.DeserializeObject>(value); + return new LuaPackedArray(dict ?? throw new Exception("Not a packed table (empty object)!")); + } + public override string ToString() => _items is { Length: 0 } + ? "Empty Array" + : string.Join(", ", _items.Select(i => i is null ? "nil" : i.ToString())); +} \ No newline at end of file diff --git a/MinecraftDiscordBot/Models/Md5Hash.cs b/MinecraftDiscordBot/Models/Md5Hash.cs index d858b8b..7d38ecd 100644 --- a/MinecraftDiscordBot/Models/Md5Hash.cs +++ b/MinecraftDiscordBot/Models/Md5Hash.cs @@ -1,5 +1,6 @@ using Newtonsoft.Json; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; namespace MinecraftDiscordBot.Models; @@ -19,7 +20,7 @@ public class Md5Hash : IEquatable { hashCode.AddBytes(_hash); return hashCode.ToHashCode(); } - public override string ToString() => Convert.ToHexString(_hash); + public override string ToString() => Convert.ToHexString(_hash).ToLower(); public class Md5JsonConverter : JsonConverter { public override Md5Hash? ReadJson(JsonReader reader, Type objectType, Md5Hash? existingValue, bool hasExistingValue, JsonSerializer serializer) @@ -31,4 +32,14 @@ public class Md5Hash : IEquatable { else writer.WriteValue(value.ToString()); } } + + public static bool TryParse(string itemid, [NotNullWhen(true)] out Md5Hash? fingerprint) { + try { + fingerprint = new Md5Hash(itemid); + return true; + } catch (Exception) { + fingerprint = null; + return false; + } + } } \ No newline at end of file diff --git a/MinecraftDiscordBot/Models/Message.cs b/MinecraftDiscordBot/Models/Message.cs index ef41d50..241802f 100644 --- a/MinecraftDiscordBot/Models/Message.cs +++ b/MinecraftDiscordBot/Models/Message.cs @@ -40,11 +40,8 @@ public class ReplyMessage : Message { 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 ResultState State { get; set; } = ResultState.Successful; public override string Type => "reply"; } @@ -62,4 +59,10 @@ public class RequestMessage : Message { [JsonProperty("params")] public Dictionary Parameters { get; } public override string Type => "request"; +} + +public enum ResultState { + Successful, + Unsuccessful, + Fatal } \ No newline at end of file diff --git a/MinecraftDiscordBot/Program.cs b/MinecraftDiscordBot/Program.cs index f779ffb..db202d3 100644 --- a/MinecraftDiscordBot/Program.cs +++ b/MinecraftDiscordBot/Program.cs @@ -12,7 +12,7 @@ using System.Runtime.CompilerServices; namespace MinecraftDiscordBot; -public class Program : IDisposable, ICommandHandler { +public class Program : IDisposable, ICommandHandler, IUserRoleManager { public const string WebSocketSource = "WebSocket"; public const string BotSource = "Bot"; private static readonly object LogLock = new(); @@ -29,7 +29,7 @@ public class Program : IDisposable, ICommandHandler { public ITextChannel[] _channels = Array.Empty(); private RootCommandService? _rsSystem = null; private bool disposedValue; - public static bool OnlineNotifications => false; + public static bool OnlineNotifications => true; private const string ClientScriptName = "MinecraftDiscordBot.ClientScript.lua"; public readonly string ClientScript; private readonly ITokenProvider _tokenProvider = new TimeoutTokenProvider(InstanceId, 10); @@ -38,7 +38,7 @@ public class Program : IDisposable, ICommandHandler { private string GetVerifiedClientScript() => ClientScript .Replace("$TOKEN", _tokenProvider.GenerateToken()); - private string GetClientScript(BotConfiguration config) { + private static string GetClientScript(BotConfiguration config) { using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(ClientScriptName); if (stream is null) throw new FileNotFoundException("Client script could not be loaded!"); using var sr = new StreamReader(stream); @@ -52,8 +52,8 @@ public class Program : IDisposable, ICommandHandler { _rsSystem = value; 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!"))); + ? $"The Minecraft client has gone offline!" + : $"The Minecraft client is now online!"))); } } } @@ -61,6 +61,7 @@ public class Program : IDisposable, ICommandHandler { private async Task Broadcast(Func> message) => _ = await Task.WhenAll(_channels.Select(message)); public Program(BotConfiguration config) { _config = config; + _administrators = config.Administrators.ToHashSet(); ClientScript = GetClientScript(config); _client.Log += LogAsync; _client.MessageReceived += (msg) => DiscordMessageReceived(msg); @@ -151,7 +152,7 @@ public class Program : IDisposable, ICommandHandler { return; } await LogInfoAsync(WebSocketSource, $"[{socket.ConnectionInfo.Id}] Client logged in with valid script!"); - AddComputerSocket(socket, new(socket)); + AddComputerSocket(socket, new(socket, this)); } private static async Task DisruptClientConnection(IWebSocketConnection socket, string reason) { @@ -182,10 +183,12 @@ public class Program : IDisposable, ICommandHandler { if (arg is not SocketUserMessage message) return; if (message.Author.IsBot) return; if (!IsChannelWhitelisted(arg.Channel)) return; + if (arg.Type is not MessageType.Default) return; var cts = new CancellationTokenSource(timeout); if (IsCommand(message, out var argPos)) { + await arg.Channel.TriggerTypingAsync(); var parameters = message.Content[argPos..].Split(WhiteSpace, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); _ = Task.Run(async () => { var response = await HandleCommand(message, parameters, cts.Token); @@ -205,6 +208,7 @@ public class Program : IDisposable, ICommandHandler { }; private readonly ConcurrentDictionary _choiceWait = new(); + private readonly HashSet _administrators; private async Task DiscordReactionAdded(Cacheable message, Cacheable channel, SocketReaction reaction) { var msgObject = await message.GetOrDownloadAsync(); @@ -293,6 +297,11 @@ public class Program : IDisposable, ICommandHandler { Dispose(disposing: true); GC.SuppressFinalize(this); } + + public void RequireAdministrator(ulong user, string? message = null) { + if (!_administrators.Contains(user)) + throw new ReplyException(message ?? "User is not authorized to access this command!"); + } } public abstract class ResponseType { diff --git a/MinecraftDiscordBot/Services/RefinedStorageService.cs b/MinecraftDiscordBot/Services/RefinedStorageService.cs index 1d7dd52..38bc0d8 100644 --- a/MinecraftDiscordBot/Services/RefinedStorageService.cs +++ b/MinecraftDiscordBot/Services/RefinedStorageService.cs @@ -2,17 +2,21 @@ using MinecraftDiscordBot.Commands; using MinecraftDiscordBot.Models; using System.Text; +using System.Text.RegularExpressions; namespace MinecraftDiscordBot.Services; public class RefinedStorageService : CommandRouter { private readonly ITaskWaitSource _taskSource; + private readonly IUserRoleManager _roleManager; public override string HelpTextPrefix => "!rs "; - public RefinedStorageService(ITaskWaitSource taskSource) : base() => _taskSource = taskSource; + public RefinedStorageService(ITaskWaitSource taskSource, IUserRoleManager roleManager) : base() { + _taskSource = taskSource; + _roleManager = roleManager; + } + public override Task FallbackHandler(SocketUserMessage message, string method, string[] parameters, CancellationToken ct) => throw new ReplyException($"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, Dictionary? parameters = null) { var waiter = _taskSource.GetWaiter(parser, ct); @@ -27,6 +31,7 @@ public class RefinedStorageService : CommandRouter { private const string CmdListFluids = "listfluids"; private const string CmdCraftItem = "craft"; private const string CmdGetItem = "getitem"; + private const string CmdCommand = "command"; public async Task GetEnergyUsageAsync(CancellationToken ct) => await Method(CmdEnergyUsage, int.Parse, ct); public async Task GetEnergyStorageAsync(CancellationToken ct) => await Method(CmdEnergyStorage, int.Parse, ct); @@ -35,10 +40,16 @@ public class RefinedStorageService : CommandRouter { public async Task GetItemData(string itemid, CancellationToken ct) => await Method(CmdGetItem, RootCommandService.Deserialize(), ct, new() { ["name"] = itemid }); + public async Task GetItemData(Md5Hash fingerprint, CancellationToken ct) => await Method(CmdGetItem, RootCommandService.Deserialize(), ct, new() { + ["fingerprint"] = fingerprint.ToString() + }); public async Task CraftItem(string itemid, int amount, CancellationToken ct) => await Method(CmdCraftItem, RootCommandService.Deserialize(), ct, new() { ["name"] = itemid, ["count"] = amount }); + public async Task RawCommand(string command, CancellationToken ct) => await Method(CmdCommand, LuaPackedArray.Deserialize, ct, new() { + ["command"] = command + }); private Task> FilterItems(SocketUserMessage message, IEnumerable filters, CancellationToken ct) => FilterItems(message, filters.Select(ItemFilter.Parse), ct); @@ -89,19 +100,12 @@ public class RefinedStorageService : CommandRouter { } [CommandHandler(CmdGetItem, HelpText = "Get information about a specific item.")] public async Task HandleGetItemData(SocketUserMessage message, string[] parameters, CancellationToken ct) { - var amount = 1; string itemid; - if (parameters.Length is 1 or 2) { - itemid = parameters[0]; - if (parameters.Length is 2) - if (int.TryParse(parameters[1], out var value)) amount = value; - else return ResponseType.AsString($"I expected an amount to craft, not '{parameters[1]}'!"); - } else return parameters.Length is < 1 - ? ResponseType.AsString("You have to give me at least an item name!") - : parameters.Length is > 2 - ? ResponseType.AsString("Yo, those are way too many arguments! I want only item name and maybe an amount!") - : throw new InvalidOperationException($"Forgot to match parameter length {parameters.Length}!"); - var item = await GetItemData(itemid, ct); + if (parameters.Length is not 1) throw new ReplyException($"I only want one name or fingerprint to search for, you gave me {parameters.Length} arguments!"); + itemid = parameters[0]; + var item = await (Md5Hash.TryParse(itemid, out var fingerprint) + ? GetItemData(fingerprint, ct) + : GetItemData(itemid, ct)); var sb = new StringBuilder(); sb.Append($"We currently have {item.Amount:n0} {item.CleanDisplayName}!"); if (item.Tags is not null and var tags) { @@ -111,6 +115,7 @@ public class RefinedStorageService : CommandRouter { sb.Append($"\nRefer to this item with fingerprint {item.Fingerprint}"); return ResponseType.AsString(sb.ToString()); } + [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..."); @@ -134,6 +139,14 @@ public class RefinedStorageService : CommandRouter { return ResponseType.AsString(sb.ToString()); } + [CommandHandler(CmdCommand, HelpText = "Runs a raw command on the RS system.")] + public async Task HandleRawCommand(SocketUserMessage message, string[] parameters, CancellationToken ct) { + _roleManager.RequireAdministrator(message.Author.Id, "You are not authorized to run raw commands on this instance!"); + var command = string.Join(' ', parameters); + var response = await RawCommand(command, ct); + return ResponseType.AsString(response.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(); diff --git a/MinecraftDiscordBot/Services/RootCommandService.cs b/MinecraftDiscordBot/Services/RootCommandService.cs index bb05307..9d28c45 100644 --- a/MinecraftDiscordBot/Services/RootCommandService.cs +++ b/MinecraftDiscordBot/Services/RootCommandService.cs @@ -12,10 +12,10 @@ public delegate Task HandleCommandDelegate(SocketUserMessage message, string[] p public class RootCommandService : CommandRouter, ITaskWaitSource { protected readonly IWebSocketConnection _socket; public override string HelpTextPrefix => "!"; - public RootCommandService(IWebSocketConnection socket) : base() { + public RootCommandService(IWebSocketConnection socket, IUserRoleManager roleManager) : base() { socket.OnMessage = OnMessage; _socket = socket; - _rs = new RefinedStorageService(this); + _rs = new RefinedStorageService(this, roleManager); } private void OnMessage(string message) { @@ -25,7 +25,7 @@ public class RootCommandService : CommandRouter, ITaskWaitSource { Program.LogWarningAsync("Socket", $"Invalid wait id '{msg.AnswerId}'!"); return; } - if (!msg.Success) waiter.SetUnsuccessful(); + waiter.SetResultState(msg.State); waiter.AddChunk(msg.Chunk, msg.Total, msg.Result); if (waiter.Finished || waiter.IsCancellationRequested) lock (_syncRoot) @@ -65,8 +65,6 @@ public class RootCommandService : CommandRouter, ITaskWaitSource { 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) => throw new ReplyException($"What the fuck do you mean by '{method}'?"); }