diff --git a/MinecraftDiscordBot/ClientScript.lua b/MinecraftDiscordBot/ClientScript.lua index 16b1d91..7137d1a 100644 --- a/MinecraftDiscordBot/ClientScript.lua +++ b/MinecraftDiscordBot/ClientScript.lua @@ -73,6 +73,12 @@ local function getResponse(parsed) return textutils.serializeJSON(item) elseif parsed.method == "command" then return runRsCommand(parsed.params) + elseif parsed.method == "getonline" then + return textutils.serializeJSON(getPeripheral("playerDetector").getOnlinePlayers()) + elseif parsed.method == "whereis" then + local pos = getPeripheral("playerDetector").getPlayerPos(parsed.params.username) + if not pos then return "null" end + return textutils.serializeJSON(pos) end error({message = "No message handler for method: "..parsed.method.."!"}) @@ -119,13 +125,7 @@ local function handleMessage(socket, message) return false end -local function socketClient() - print("Connecting to the socket server at "..connectionUri.."...") - local socket, reason = http.websocket(connectionUri) - if not socket then error("Socket server could not be reached: "..reason) end - print("Connection successful!") - - socket.send("login="..secretToken) +local function responder(socket) while true do local message, binary = socket.receive() if not not message and not binary then @@ -142,15 +142,60 @@ local function termWaiter() os.pullEvent("terminate") end -local function services() - parallel.waitForAny(termWaiter, function() - parallel.waitForAll(socketClient) - end) +local function chatEventListener(socket) + while true do + event, username, message, uuid, hidden = os.pullEvent("chat") + sendJson(socket, {type = "chat", username = username, message = message, uuid = uuid, hidden = hidden}) + print("Chat event relayed!") + end +end + +local function getPeripheralInfo(side) + return {type = peripheral.getType(side), methods = peripheral.getMethods(side), side = side} +end + +local function peripheralDetachEventListener(socket) + while true do + event, side = os.pullEvent("peripheral_detach") + sendJson(socket, {type = "peripheral_detach", side = side}) + print("Peripheral was detached!") + end +end + +local function peripheralAttachEventListener(socket) + while true do + event, side = os.pullEvent("peripheral") + sendJson(socket, {type = "peripheral", peripheral = getPeripheralInfo(side) }) + print("Peripheral was attached!") + end +end + +local function eventListeners(socket) + parallel.waitForAny( + termWaiter, + function() chatEventListener(socket) end, + function() peripheralDetachEventListener(socket) end, + function() peripheralAttachEventListener(socket) end + ) +end + +local function socketClient() + print("Connecting to the socket server at "..connectionUri.."...") + local socket, reason = http.websocket(connectionUri) + if not socket then error("Socket server could not be reached: "..reason) end + print("Connection successful!") + + socket.send("login="..secretToken) + parallel.waitForAny( + function() responder(socket) end, + function() eventListeners(socket) end + ) + socket.close() end local function main() while true do - local status, error = pcall(services) + local status, error = pcall(socketClient) if status then break end printError("An uncaught exception was raised:", error) printError("Restarting in", waitSeconds, "seconds...") diff --git a/MinecraftDiscordBot/Commands/CommandRouter.cs b/MinecraftDiscordBot/Commands/CommandRouter.cs index 623017f..0462710 100644 --- a/MinecraftDiscordBot/Commands/CommandRouter.cs +++ b/MinecraftDiscordBot/Commands/CommandRouter.cs @@ -1,4 +1,5 @@ using Discord.WebSocket; +using MinecraftDiscordBot.Models; using MinecraftDiscordBot.Services; using System.Reflection; using System.Text; @@ -8,6 +9,7 @@ namespace MinecraftDiscordBot.Commands; public abstract class CommandRouter : ICommandHandler { public record struct HandlerStruct(HandleCommandDelegate Delegate, CommandHandlerAttribute Attribute); private readonly Dictionary _handlers = new(); + public abstract string HelpTextPrefix { get; } public CommandRouter() { foreach (var method in GetType().GetMethods()) diff --git a/MinecraftDiscordBot/Models/Message.cs b/MinecraftDiscordBot/Models/Message.cs index 7c09d22..40bf9d7 100644 --- a/MinecraftDiscordBot/Models/Message.cs +++ b/MinecraftDiscordBot/Models/Message.cs @@ -64,12 +64,37 @@ public class ReplyMessage : Message { public abstract class EventMessage : Message { } +[MessageType(TYPE)] +public class PeripheralDetachEvent : EventMessage { + private const string TYPE = "peripheral_detach"; + public override string Type => TYPE; + [JsonProperty("side", Required = Required.Always)] + public string Side { get; set; } = default!; +} + +[MessageType(TYPE)] +public class PeripheralAttachEvent : EventMessage { + private const string TYPE = "peripheral"; + public override string Type => TYPE; + [JsonIgnore] + public string Side => Peripheral.Side; + [JsonProperty("peripheral", Required = Required.Always)] + public Peripheral Peripheral { get; set; } = default!; +} + +public class Peripheral { + [JsonProperty("side", Required = Required.Always)] + public string Side { get; set; } = default!; + [JsonProperty("type", Required = Required.Always)] + public string Type { get; set; } = default!; + [JsonProperty("methods", Required = Required.Always)] + public string[] Methods { get; set; } = default!; +} + [MessageType(TYPE)] public class ChatEvent : EventMessage { private const string TYPE = "chat"; public override string Type => TYPE; - [JsonProperty("name", Required = Required.Always)] - public string Name { get; set; } = default!; [JsonProperty("username", Required = Required.Always)] public string Username { get; set; } = default!; [JsonProperty("message", Required = Required.Always)] diff --git a/MinecraftDiscordBot/Models/PlayerPosition.cs b/MinecraftDiscordBot/Models/PlayerPosition.cs new file mode 100644 index 0000000..5978e74 --- /dev/null +++ b/MinecraftDiscordBot/Models/PlayerPosition.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; + +namespace MinecraftDiscordBot.Models; + +public class PlayerPosition { + [JsonProperty("dimension", Required = Required.Always)] public string Dimension { get; set; } = default!; + [JsonProperty("eyeHeight", Required = Required.Always)] public double EyeHeight { get; set; } + [JsonProperty("pitch", Required = Required.Always)] public double Pitch { get; set; } + [JsonProperty("yaw", Required = Required.Always)] public double Yaw { get; set; } + [JsonProperty("x", Required = Required.Always)] public int X { get; set; } + [JsonProperty("y", Required = Required.Always)] public int Y { get; set; } + [JsonProperty("z", Required = Required.Always)] public int Z { get; set; } +} \ No newline at end of file diff --git a/MinecraftDiscordBot/Program.cs b/MinecraftDiscordBot/Program.cs index db202d3..3474a1b 100644 --- a/MinecraftDiscordBot/Program.cs +++ b/MinecraftDiscordBot/Program.cs @@ -27,8 +27,9 @@ public class Program : IDisposable, ICommandHandler, IUserRoleMana private readonly ConcurrentDictionary _connections = new(); private static readonly char[] WhiteSpace = new char[] { '\t', '\n', ' ', '\r' }; public ITextChannel[] _channels = Array.Empty(); - private RootCommandService? _rsSystem = null; private bool disposedValue; + private readonly RootCommandService _computer; + public static bool OnlineNotifications => true; private const string ClientScriptName = "MinecraftDiscordBot.ClientScript.lua"; public readonly string ClientScript; @@ -46,21 +47,10 @@ public class Program : IDisposable, ICommandHandler, IUserRoleMana .Replace("$HOST", $"ws://{config.SocketHost}:{config.Port}"); } - public RootCommandService? Computer { - get => _rsSystem; set { - if (_rsSystem != value) { - _rsSystem = value; - if (OnlineNotifications) - _ = Task.Run(() => Broadcast(i => i.SendMessageAsync(value is null - ? $"The Minecraft client has gone offline!" - : $"The Minecraft client is now online!"))); - } - } - } - private async Task Broadcast(Func> message) => _ = await Task.WhenAll(_channels.Select(message)); public Program(BotConfiguration config) { _config = config; + _computer = new(this); _administrators = config.Administrators.ToHashSet(); ClientScript = GetClientScript(config); _client.Log += LogAsync; @@ -142,17 +132,21 @@ public class Program : IDisposable, ICommandHandler, IUserRoleMana await (message switch { "getcode" => SendClientCode(socket), string s when s.StartsWith("login=") => ClientComputerConnected(socket, s[6..]), + string s when s.StartsWith("error=") => ClientComputerError(socket, s[6..]), _ => DisruptClientConnection(socket, "Protocol violation!") }); } + private static async Task ClientComputerError(IWebSocketConnection socket, string message) + => await LogWarningAsync("Client", $"Computer failed to run the script: {message}"); + private async Task ClientComputerConnected(IWebSocketConnection socket, string token) { if (!_tokenProvider.VerifyToken(token)) { await DisruptClientConnection(socket, "outdated"); return; } await LogInfoAsync(WebSocketSource, $"[{socket.ConnectionInfo.Id}] Client logged in with valid script!"); - AddComputerSocket(socket, new(socket, this)); + AddComputerSocket(socket); } private static async Task DisruptClientConnection(IWebSocketConnection socket, string reason) { @@ -166,10 +160,10 @@ public class Program : IDisposable, ICommandHandler, IUserRoleMana await LogInfoAsync(WebSocketSource, $"[{socket.ConnectionInfo.Id}] Script sent to client!"); } - private void AddComputerSocket(IWebSocketConnection socket, RootCommandService pc) => Computer = pc; + private void AddComputerSocket(IWebSocketConnection socket) => _computer.Socket = socket; private void RemoveComputerSocket(IWebSocketConnection socket) { - if (Computer is { ConnectionInfo.Id: Guid id } && id == socket.ConnectionInfo.Id) Computer = null; + if (_computer.Socket is { ConnectionInfo.Id: Guid id } && id == socket.ConnectionInfo.Id) _computer.Socket = null; } private async Task SocketClosed(IWebSocketConnection socket) { @@ -232,7 +226,7 @@ public class Program : IDisposable, ICommandHandler, IUserRoleMana } public async Task HandleCommand(SocketUserMessage message, string[] parameters, CancellationToken ct) { - if (Computer is ICommandHandler handler) + if (_computer is ICommandHandler handler) try { return await handler.HandleCommand(message, parameters, ct); } catch (TaskCanceledException) { diff --git a/MinecraftDiscordBot/Services/PlayerDetectorService.cs b/MinecraftDiscordBot/Services/PlayerDetectorService.cs new file mode 100644 index 0000000..27b3dea --- /dev/null +++ b/MinecraftDiscordBot/Services/PlayerDetectorService.cs @@ -0,0 +1,37 @@ +using Discord.WebSocket; +using MinecraftDiscordBot.Commands; +using MinecraftDiscordBot.Models; +using Newtonsoft.Json; + +namespace MinecraftDiscordBot.Services; + +internal class PlayerDetectorService : CommandRouter { + private readonly ITaskWaitSource _taskSource; + public PlayerDetectorService(ITaskWaitSource taskSource) => _taskSource = taskSource; + + public override string HelpTextPrefix => "!pd "; + public override Task FallbackHandler(SocketUserMessage message, string method, string[] parameters, CancellationToken ct) + => throw new ReplyException($"The player detector cannot do '{method}'!"); + + 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 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() { + ["username"] = username + }); + + [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))}"); + [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); + 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}."); + } +} \ No newline at end of file diff --git a/MinecraftDiscordBot/Services/RefinedStorageService.cs b/MinecraftDiscordBot/Services/RefinedStorageService.cs index 38bc0d8..639d8ce 100644 --- a/MinecraftDiscordBot/Services/RefinedStorageService.cs +++ b/MinecraftDiscordBot/Services/RefinedStorageService.cs @@ -18,11 +18,8 @@ public class RefinedStorageService : CommandRouter { public override Task FallbackHandler(SocketUserMessage message, string method, string[] parameters, CancellationToken ct) => throw new ReplyException($"The RS system has no command '{method}'!"); - private async Task Method(string methodName, Func parser, CancellationToken ct, Dictionary? parameters = null) { - var waiter = _taskSource.GetWaiter(parser, ct); - await _taskSource.Send(new RequestMessage(waiter.ID, methodName, parameters)); - return await waiter.Task; - } + private Task Method(string methodName, Func parser, CancellationToken ct, Dictionary? parameters = null) + => RootCommandService.Method(_taskSource, methodName, parser, ct, parameters); private const string CmdEnergyUsage = "energyusage"; private const string CmdEnergyStorage = "energystorage"; diff --git a/MinecraftDiscordBot/Services/RootCommandService.cs b/MinecraftDiscordBot/Services/RootCommandService.cs index f1fbc5c..258e0bb 100644 --- a/MinecraftDiscordBot/Services/RootCommandService.cs +++ b/MinecraftDiscordBot/Services/RootCommandService.cs @@ -10,16 +10,45 @@ public delegate Task HandleCommandDelegate(SocketUserMessa public delegate Task HandleCommandDelegate(SocketUserMessage message, string[] parameters, CancellationToken ct); public class RootCommandService : CommandRouter, ITaskWaitSource { - protected readonly IWebSocketConnection _socket; + protected IWebSocketConnection? _socketField; public override string HelpTextPrefix => "!"; - public RootCommandService(IWebSocketConnection socket, IUserRoleManager roleManager) : base() { - socket.OnMessage = OnMessage; - _socket = socket; + public RootCommandService(IUserRoleManager roleManager) : base() { _rs = new RefinedStorageService(this, roleManager); + _pd = new PlayerDetectorService(this); + } + + public static async Task Method(ITaskWaitSource taskSource, string methodName, Func parser, CancellationToken ct, Dictionary? parameters) { + var waiter = taskSource.GetWaiter(parser, ct); + await taskSource.Send(new RequestMessage(waiter.ID, methodName, parameters)); + return await waiter.Task; + } + + public event EventHandler? ChatMessageReceived; + public event EventHandler? PeripheralAttached; + public event EventHandler? PeripheralDetached; + public event EventHandler? SocketChanged; + + public IWebSocketConnection? Socket { + get => _socketField; set { + if (_socketField != value) { + _socketField = value; + if (value is not null) value.OnMessage = OnMessage; + SocketChanged?.Invoke(this, value); + } + } } private void OnMessage(string message) { switch (Message.Deserialize(message)) { + case ChatEvent msg: + ChatMessageReceived?.Invoke(this, msg); + break; + case PeripheralAttachEvent msg: + PeripheralAttached?.Invoke(this, msg); + break; + case PeripheralDetachEvent msg: + PeripheralDetached?.Invoke(this, msg); + break; case ReplyMessage msg: IChunkWaiter? waiter; lock (_syncRoot) if (!_waits.TryGetValue(msg.AnswerId, out waiter)) { @@ -38,12 +67,11 @@ public class RootCommandService : CommandRouter, ITaskWaitSource { } } - public Task Send(string message) => _socket.Send(message); + public Task Send(string message) => (Socket ?? throw new ReplyException("Minecraft server is not available!")).Send(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; private int GetFreeId() { var attempts = 0; @@ -65,9 +93,14 @@ public class RootCommandService : CommandRouter, ITaskWaitSource { } 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); + [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); public static Func Deserialize() => msg => JsonConvert.DeserializeObject(msg) ?? throw new InvalidProgramException("Empty response!");