From f912b9db8f5f1d16a4e51e45e716b597c158aa8f Mon Sep 17 00:00:00 2001 From: Michael Chen Date: Tue, 18 Jan 2022 10:10:49 +0100 Subject: [PATCH] Re-added chat relaying Fixed async naming scheme Added webhook for every channel Added colorful logging --- MinecraftDiscordBot/ClientScript.lua | 7 +++ MinecraftDiscordBot/Program.cs | 49 ++++++++++++++++--- .../Services/ChatBoxService.cs | 26 ++++++++++ .../Services/PlayerDetectorService.cs | 14 +++--- .../Services/RefinedStorageService.cs | 18 +++---- .../Services/RootCommandService.cs | 19 ++++--- 6 files changed, 104 insertions(+), 29 deletions(-) create mode 100644 MinecraftDiscordBot/Services/ChatBoxService.cs diff --git a/MinecraftDiscordBot/ClientScript.lua b/MinecraftDiscordBot/ClientScript.lua index 7137d1a..514a97a 100644 --- a/MinecraftDiscordBot/ClientScript.lua +++ b/MinecraftDiscordBot/ClientScript.lua @@ -79,6 +79,13 @@ local function getResponse(parsed) local pos = getPeripheral("playerDetector").getPlayerPos(parsed.params.username) if not pos then return "null" end return textutils.serializeJSON(pos) + elseif parsed.method == "send" then + if not parsed.params.username then + getPeripheral("chatBox").sendMessage(parsed.params.message, parsed.params.prefix) + else + getPeripheral("chatBox").sendMessageToPlayer(parsed.params.message, parsed.params.username, parsed.params.prefix) + end + return "true" end error({message = "No message handler for method: "..parsed.method.."!"}) diff --git a/MinecraftDiscordBot/Program.cs b/MinecraftDiscordBot/Program.cs index 3474a1b..c41b8b4 100644 --- a/MinecraftDiscordBot/Program.cs +++ b/MinecraftDiscordBot/Program.cs @@ -2,9 +2,11 @@ using Discord; using Discord.Commands; using Discord.Rest; +using Discord.Webhook; using Discord.WebSocket; using Fleck; using MinecraftDiscordBot.Commands; +using MinecraftDiscordBot.Models; using MinecraftDiscordBot.Services; using System.Collections.Concurrent; using System.Reflection; @@ -26,12 +28,14 @@ public class Program : IDisposable, ICommandHandler, IUserRoleMana private readonly HashSet _whitelistedChannels; private readonly ConcurrentDictionary _connections = new(); private static readonly char[] WhiteSpace = new char[] { '\t', '\n', ' ', '\r' }; - public ITextChannel[] _channels = Array.Empty(); + public IEnumerable Channels => _channels ?? throw new InvalidProgramException("Channels used before verification!"); + public ActiveChannel[]? _channels; private bool disposedValue; private readonly RootCommandService _computer; public static bool OnlineNotifications => true; private const string ClientScriptName = "MinecraftDiscordBot.ClientScript.lua"; + private const string WebhookName = "minecraftbot"; public readonly string ClientScript; private readonly ITokenProvider _tokenProvider = new TimeoutTokenProvider(InstanceId, 10); private static readonly int InstanceId = new Random().Next(); @@ -47,10 +51,12 @@ public class Program : IDisposable, ICommandHandler, IUserRoleMana .Replace("$HOST", $"ws://{config.SocketHost}:{config.Port}"); } - private async Task Broadcast(Func> message) => _ = await Task.WhenAll(_channels.Select(message)); + private Task Broadcast(Func> message) + => Task.WhenAll(Channels.Select(i => message(i.Channel))); public Program(BotConfiguration config) { _config = config; _computer = new(this); + _computer.ChatMessageReceived += MinecraftMessageReceived; _administrators = config.Administrators.ToHashSet(); ClientScript = GetClientScript(config); _client.Log += LogAsync; @@ -63,6 +69,9 @@ public class Program : IDisposable, ICommandHandler, IUserRoleMana _whitelistedChannels = config.Channels.ToHashSet(); } + private void MinecraftMessageReceived(object? sender, ChatEvent e) + => Task.Run(() => WebhookBroadcast(i => i.SendMessageAsync(e.Message, username: e.Username, avatarUrl: $"https://crafatar.com/renders/head/{e.UUID}"))); + private Task WebhookBroadcast(Func> apply) => Task.WhenAll(Channels.Select(i => apply(new DiscordWebhookClient(i.Webhook)))); private void LogWebSocket(LogLevel level, string message, Exception exception) => Log(new(level switch { LogLevel.Debug => LogSeverity.Debug, LogLevel.Info => LogSeverity.Info, @@ -98,7 +107,11 @@ public class Program : IDisposable, ICommandHandler, IUserRoleMana await LogErrorAsync(BotSource, new InvalidOperationException("No valid textchannel was whitelisted!")); return false; } - _channels = channels; + _channels = await Task.WhenAll(channels.Select(async i => new ActiveChannel(i, await GetOrCreateWebhook(i)))); + static async Task GetOrCreateWebhook(ITextChannel i) { + var hooks = (await i.GetWebhooksAsync()).Where(i => i.Name == WebhookName).FirstOrDefault(); + return hooks ?? await i.CreateWebhookAsync(WebhookName); + } return true; } @@ -192,7 +205,7 @@ public class Program : IDisposable, ICommandHandler, IUserRoleMana } await LogInfoAsync("Discord", $"[{arg.Author.Username}] {arg.Content}"); - // TODO: Relay Message to Chat Receiver + _ = Task.Run(() => _computer.Chat.SendMessageAsync(arg.Content, arg.Author.Username, cts.Token)); } private Task SendResponse(SocketUserMessage message, ResponseType response) => response switch { @@ -261,8 +274,22 @@ public class Program : IDisposable, ICommandHandler, IUserRoleMana } public static void Log(LogMessage msg) { - lock (LogLock) - Console.WriteLine(msg.ToString()); + var oldColor = Console.ForegroundColor; + try { + Console.ForegroundColor = msg.Severity switch { + LogSeverity.Critical => ConsoleColor.Magenta, + LogSeverity.Error => ConsoleColor.Red, + LogSeverity.Warning => ConsoleColor.Yellow, + LogSeverity.Info => ConsoleColor.White, + LogSeverity.Verbose => ConsoleColor.Blue, + LogSeverity.Debug => ConsoleColor.DarkBlue, + _ => ConsoleColor.Cyan, + }; + lock (LogLock) + Console.WriteLine(msg.ToString()); + } finally { + Console.ForegroundColor = oldColor; + } } protected virtual void Dispose(bool disposing) { @@ -298,6 +325,16 @@ public class Program : IDisposable, ICommandHandler, IUserRoleMana } } +public class ActiveChannel { + public ActiveChannel(ITextChannel channel, IWebhook webhook) { + Channel = channel; + Webhook = webhook; + } + + public IWebhook Webhook { get; } + public ITextChannel Channel { get; } +} + 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); diff --git a/MinecraftDiscordBot/Services/ChatBoxService.cs b/MinecraftDiscordBot/Services/ChatBoxService.cs new file mode 100644 index 0000000..e24349f --- /dev/null +++ b/MinecraftDiscordBot/Services/ChatBoxService.cs @@ -0,0 +1,26 @@ +using Discord.WebSocket; +using MinecraftDiscordBot.Commands; + +namespace MinecraftDiscordBot.Services; + +public class ChatBoxService : CommandRouter { + private readonly ITaskWaitSource _taskSource; + public ChatBoxService(ITaskWaitSource taskSource) => _taskSource = taskSource; + + public override string HelpTextPrefix => "!chat "; + public override Task FallbackHandler(SocketUserMessage message, string method, string[] parameters, CancellationToken ct) + => throw new ReplyException($"The chat box cannot do '{method}'!"); + + private Task Method(string methodName, Func parser, CancellationToken ct, Dictionary? parameters = null) + => RootCommandService.Method(_taskSource, methodName, parser, ct, parameters); + + public Task SendMessageAsync(string message, string prefix, CancellationToken ct) => Method("send", RootCommandService.Deserialize(), ct, new() { + ["message"] = message, + ["prefix"] = prefix + }); + public Task SendMessageToPlayerAsync(string message, string username, string prefix, CancellationToken ct) => Method("send", RootCommandService.Deserialize(), ct, new() { + ["message"] = message, + ["username"] = username, + ["prefix"] = prefix + }); +} diff --git a/MinecraftDiscordBot/Services/PlayerDetectorService.cs b/MinecraftDiscordBot/Services/PlayerDetectorService.cs index 27b3dea..072fef7 100644 --- a/MinecraftDiscordBot/Services/PlayerDetectorService.cs +++ b/MinecraftDiscordBot/Services/PlayerDetectorService.cs @@ -5,7 +5,7 @@ using Newtonsoft.Json; namespace MinecraftDiscordBot.Services; -internal class PlayerDetectorService : CommandRouter { +public class PlayerDetectorService : CommandRouter { private readonly ITaskWaitSource _taskSource; public PlayerDetectorService(ITaskWaitSource taskSource) => _taskSource = taskSource; @@ -16,21 +16,21 @@ internal class PlayerDetectorService : CommandRouter { private Task Method(string methodName, Func parser, CancellationToken ct, Dictionary? parameters = null) => RootCommandService.Method(_taskSource, methodName, parser, ct, parameters); - public Task GetOnlinePlayers(CancellationToken ct) => Method("getonline", RootCommandService.Deserialize(), ct); + public Task GetOnlinePlayersAsync(CancellationToken ct) => Method("getonline", RootCommandService.Deserialize(), ct); public async Task GetPlayerPosition(string username, CancellationToken ct) - => (await FindPlayer(username, ct)) ?? throw new ReplyException($"User '{username}' is not online!"); - private Task FindPlayer(string username, CancellationToken ct) => Method("whereis", i => JsonConvert.DeserializeObject(i), ct, new() { + => (await FindPlayerAsync(username, ct)) ?? throw new ReplyException($"User '{username}' is not online!"); + private Task FindPlayerAsync(string username, CancellationToken ct) => Method("whereis", i => JsonConvert.DeserializeObject(i), ct, new() { ["username"] = username }); - [CommandHandler("getonline", HelpText ="Get a list of online players.")] + [CommandHandler("getonline", HelpText = "Get a list of online players.")] public async Task HandleOnlinePlayers(SocketUserMessage message, string[] parameters, CancellationToken ct) - => ResponseType.AsString($"The following players are currently online:\n{string.Join("\n", await GetOnlinePlayers(ct))}"); + => ResponseType.AsString($"The following players are currently online:\n{string.Join("\n", await GetOnlinePlayersAsync(ct))}"); [CommandHandler("whereis", HelpText = "Find a player in the world.")] public async Task HandleFindPlayers(SocketUserMessage message, string[] parameters, CancellationToken ct) { if (parameters is not { Length: 1 }) throw new ReplyException($"Give me only one username!"); var username = parameters[0]; - var player = await FindPlayer(username, ct); + var player = await FindPlayerAsync(username, ct); if (player is null) throw new ReplyException($"{username} is currently offline!"); return ResponseType.AsString($"{username} is at coordinates {player.X} {player.Y} {player.Z} in dimension {player.Dimension}."); } diff --git a/MinecraftDiscordBot/Services/RefinedStorageService.cs b/MinecraftDiscordBot/Services/RefinedStorageService.cs index 639d8ce..9224e26 100644 --- a/MinecraftDiscordBot/Services/RefinedStorageService.cs +++ b/MinecraftDiscordBot/Services/RefinedStorageService.cs @@ -19,7 +19,7 @@ public class RefinedStorageService : CommandRouter { => throw new ReplyException($"The RS system has no command '{method}'!"); private Task Method(string methodName, Func parser, CancellationToken ct, Dictionary? parameters = null) - => RootCommandService.Method(_taskSource, methodName, parser, ct, parameters); + => RootCommandService.Method(_taskSource, methodName, parser, ct, parameters); private const string CmdEnergyUsage = "energyusage"; private const string CmdEnergyStorage = "energystorage"; @@ -34,17 +34,17 @@ public class RefinedStorageService : CommandRouter { public async Task GetEnergyStorageAsync(CancellationToken ct) => await Method(CmdEnergyStorage, int.Parse, ct); public async Task> ListItemsAsync(CancellationToken ct) => await Method(CmdListItems, RootCommandService.Deserialize>(), ct); public async Task> ListFluidsAsync(CancellationToken ct) => await Method(CmdListFluids, RootCommandService.Deserialize>(), ct); - public async Task GetItemData(string itemid, CancellationToken ct) => await Method(CmdGetItem, RootCommandService.Deserialize(), ct, new() { + public async Task GetItemDataAsync(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() { + public async Task GetItemDataAsync(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() { + public async Task CraftItemAsync(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() { + public async Task RawCommandAsync(string command, CancellationToken ct) => await Method(CmdCommand, LuaPackedArray.Deserialize, ct, new() { ["command"] = command }); @@ -91,7 +91,7 @@ public class RefinedStorageService : CommandRouter { : parameters.Length is > 2 ? throw new ReplyException("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}!"); - return await CraftItem(itemid, amount, ct) + return await CraftItemAsync(itemid, amount, ct) ? ResponseType.AsString($"Alright, I'm starting to craft {amount} {itemid}.") : ResponseType.AsString($"Nope, that somehow doesn't work!"); } @@ -101,8 +101,8 @@ public class RefinedStorageService : CommandRouter { 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)); + ? GetItemDataAsync(fingerprint, ct) + : GetItemDataAsync(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) { @@ -140,7 +140,7 @@ public class RefinedStorageService : CommandRouter { 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); + var response = await RawCommandAsync(command, ct); return ResponseType.AsString(response.ToString()); } diff --git a/MinecraftDiscordBot/Services/RootCommandService.cs b/MinecraftDiscordBot/Services/RootCommandService.cs index 258e0bb..0e6dd99 100644 --- a/MinecraftDiscordBot/Services/RootCommandService.cs +++ b/MinecraftDiscordBot/Services/RootCommandService.cs @@ -13,8 +13,9 @@ public class RootCommandService : CommandRouter, ITaskWaitSource { protected IWebSocketConnection? _socketField; public override string HelpTextPrefix => "!"; public RootCommandService(IUserRoleManager roleManager) : base() { - _rs = new RefinedStorageService(this, roleManager); - _pd = new PlayerDetectorService(this); + RefinedStorage = new RefinedStorageService(this, roleManager); + Players = new PlayerDetectorService(this); + Chat = new ChatBoxService(this); } public static async Task Method(ITaskWaitSource taskSource, string methodName, Func parser, CancellationToken ct, Dictionary? parameters) { @@ -38,6 +39,10 @@ public class RootCommandService : CommandRouter, ITaskWaitSource { } } + public RefinedStorageService RefinedStorage { get; } + public PlayerDetectorService Players { get; } + public ChatBoxService Chat { get; } + private void OnMessage(string message) { switch (Message.Deserialize(message)) { case ChatEvent msg: @@ -92,15 +97,15 @@ public class RootCommandService : CommandRouter, ITaskWaitSource { return waiter; } - private readonly ICommandHandler _rs; - private readonly ICommandHandler _pd; - [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); + => RefinedStorage.HandleCommand(message, parameters, ct); [CommandHandler("pd", HelpText = "Provides some commands for interacting with the Player Detector.")] public Task PlayerDetectorHandler(SocketUserMessage message, string[] parameters, CancellationToken ct) - => _pd.HandleCommand(message, parameters, ct); + => Players.HandleCommand(message, parameters, ct); + [CommandHandler("chat", HelpText = "Provides some commands for chatting.")] + public Task ChatBoxHandler(SocketUserMessage message, string[] parameters, CancellationToken ct) + => Chat.HandleCommand(message, parameters, ct); public static Func Deserialize() => msg => JsonConvert.DeserializeObject(msg) ?? throw new InvalidProgramException("Empty response!");