From 1efa55206f698582dd63565545e6dabb0a58dc10 Mon Sep 17 00:00:00 2001 From: Anon Date: Sat, 27 May 2023 19:46:28 +0200 Subject: [PATCH 1/2] Re-added WebSocketChat bot and improved it, no longer using WebsocketSharp library --- MinecraftClient/ChatBots/WebSocketBot.cs | 1313 +++++++++++++++++ MinecraftClient/Json.cs | 200 ++- MinecraftClient/McClient.cs | 1 + .../ConfigComments/ConfigComments.Designer.cs | 30 + .../ConfigComments/ConfigComments.resx | 15 + .../Translations/Translations.Designer.cs | 66 + .../Resources/Translations/Translations.resx | 33 + MinecraftClient/Scripting/ChatBot.cs | 2 +- MinecraftClient/Settings.cs | 7 + 9 files changed, 1642 insertions(+), 25 deletions(-) create mode 100644 MinecraftClient/ChatBots/WebSocketBot.cs diff --git a/MinecraftClient/ChatBots/WebSocketBot.cs b/MinecraftClient/ChatBots/WebSocketBot.cs new file mode 100644 index 0000000000..b4e7df6435 --- /dev/null +++ b/MinecraftClient/ChatBots/WebSocketBot.cs @@ -0,0 +1,1313 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Net.WebSockets; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using MinecraftClient.CommandHandler; +using MinecraftClient.Inventory; +using MinecraftClient.Mapping; +using MinecraftClient.Scripting; +using Newtonsoft.Json; +using Tomlet.Attributes; + +namespace MinecraftClient.ChatBots; + +internal class SessionEventArgs : EventArgs +{ + public string SessionId { get; } + + public SessionEventArgs(string sessionId) + { + SessionId = sessionId; + } +} + +internal class MessageReceivedEventArgs : EventArgs +{ + public string SessionId { get; } + public string Message { get; } + + public MessageReceivedEventArgs(string sessionId, string message) + { + SessionId = sessionId; + Message = message; + } +} + +internal class WebSocketServer +{ + public readonly ConcurrentDictionary Sessions; + public event EventHandler? NewSession; + public event EventHandler? SessionDropped; + public event EventHandler? MessageReceived; + + private HttpListener? listener; + + public WebSocketServer() + { + Sessions = new ConcurrentDictionary(); + } + + public async Task Start(string ipAddress, int port) + { + listener = new HttpListener(); + listener.Prefixes.Add($"http://{ipAddress}:{port}/"); + listener.Start(); + + while (listener.IsListening) + { + var context = await listener.GetContextAsync(); + if (context.Request.IsWebSocketRequest) + { + var sessionGuid = Guid.NewGuid().ToString(); + var webSocketContext = await context.AcceptWebSocketAsync(null); + var webSocket = webSocketContext.WebSocket; + Sessions.TryAdd(sessionGuid, webSocket); + NewSession?.Invoke(this, new SessionEventArgs(sessionGuid)); + _ = ProcessWebSocketSession(sessionGuid, webSocket); + } + else + { + context.Response.StatusCode = 400; + context.Response.Close(); + } + } + } + + public async Task Stop() + { + foreach (var session in Sessions) + { + await session.Value.CloseAsync(WebSocketCloseStatus.NormalClosure, "Server shutting down", + CancellationToken.None); + } + + Sessions.Clear(); + listener?.Stop(); + } + + private async Task ProcessWebSocketSession(string sessionId, WebSocket webSocket) + { + var buffer = new byte[1024]; + + try + { + while (webSocket.State == WebSocketState.Open) + { + var receiveResult = + await webSocket.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); + + if (receiveResult.MessageType == WebSocketMessageType.Text) + { + var message = Encoding.UTF8.GetString(buffer, 0, receiveResult.Count); + MessageReceived?.Invoke(this, new MessageReceivedEventArgs(sessionId, message)); + } + else if (receiveResult.MessageType == WebSocketMessageType.Close) + { + await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Connection closed by the client", + CancellationToken.None); + break; + } + } + } + finally + { + Sessions.TryRemove(sessionId, out _); + SessionDropped?.Invoke(this, new SessionEventArgs(sessionId)); + } + } + + public bool RenameSession(string oldSessionId, string newSessionId) + { + if (!Sessions.ContainsKey(oldSessionId) || Sessions.ContainsKey(newSessionId)) + return false; + + if (!Sessions.TryRemove(oldSessionId, out var webSocket)) + return false; + + if (Sessions.TryAdd(newSessionId, webSocket)) + return true; + + if (!Sessions.TryAdd(oldSessionId, webSocket)) + { + // handle the rare case when adding back the old session fails + throw new Exception("Failed to add back the old session after failed rename"); + } + + return false; + } + + public async Task SendToSession(string sessionId, string message) + { + try + { + if (Sessions.TryGetValue(sessionId, out var webSocket)) + { + var buffer = Encoding.UTF8.GetBytes(message); + await webSocket.SendAsync(new ArraySegment(buffer), WebSocketMessageType.Text, true, + CancellationToken.None); + } + } + catch (WebSocketException ex) + { + if (ex.InnerException is SocketException { SocketErrorCode: SocketError.ConnectionReset }) + { + if (Sessions.ContainsKey(sessionId)) + Sessions.Remove(sessionId, out _); + } + } + } +} + +internal class WsChatBotCommand +{ + [JsonProperty("command")] public string Command { get; set; } = ""; + + [JsonProperty("requestId")] public string RequestId { get; set; } = ""; + + [JsonProperty("parameters")] public object[]? Parameters { get; set; } +} + +internal class WsCommandResponder +{ + private WebSocketBot _bot; + private string _sessionId; + private string _command; + private string _requestId; + + public WsCommandResponder(WebSocketBot bot, string sessionId, string command, string requestId) + { + _bot = bot; + _sessionId = sessionId; + _command = command; + _requestId = requestId; + } + + private void SendCommandResponse(bool success, string result, bool overrideAuth = false) + { + _bot.SendCommandResponse(_sessionId, success, _requestId, _command, result, overrideAuth); + } + + public void SendErrorResponse(string error, bool overrideAuth = false) + { + SendCommandResponse(false, error, overrideAuth); + } + + public void SendSuccessResponse(string result, bool overrideAuth = false) + { + SendCommandResponse(true, result, overrideAuth); + } + + public void SendSuccessResponse(bool overrideAuth = false) + { + SendSuccessResponse(JsonConvert.SerializeObject(true), overrideAuth); + } + + public string Quote(string text) + { + return $"\"{text}\""; + } +} + +internal class NbtData +{ + public NBT? nbt { get; set; } +} + +internal class NBT +{ + public Dictionary? nbt { get; set; } +} + +internal class NbtDictionaryConverter : JsonConverter> +{ + public override void WriteJson(JsonWriter writer, Dictionary? value, JsonSerializer serializer) + => throw new NotImplementedException(); + + public override Dictionary? ReadJson(JsonReader reader, Type objectType, + Dictionary? existingValue, bool hasExistingValue, JsonSerializer serializer) + { + var keyValuePairs = serializer.Deserialize>>(reader); + return new(keyValuePairs!); + } +} + +public class WebSocketBot : ChatBot +{ + private string? _ip; + private int _port; + private string? _password; + private WebSocketServer? _server; + private List _authenticatedSessions; + private List<(string, string)> _waitingEvents; + + public static Configs Config = new(); + + [TomlDoNotInlineObject] + public class Configs + { + [NonSerialized] private const string BotName = "Websocket"; + + public bool Enabled = false; + + [TomlInlineComment("$ChatBot.WebSocketBot.Ip$")] + public string? Ip = "127.0.0.1"; + + [TomlInlineComment("$ChatBot.WebSocketBot.Port$")] + public int Port = 8043; + + [TomlInlineComment("$ChatBot.WebSocketBot.Password$")] + public string? Password = "wspass12345"; + + [TomlInlineComment("$ChatBot.WebSocketBot.DebugMode$")] + public bool DebugMode = false; + } + + public WebSocketBot() + { + var match = Regex.Match(Config.Ip!, @"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}"); + + if (!match.Success) + { + LogToConsole(Translations.bot_WebSocketBot_failed_to_start_ip); + return; + } + + if (Config.Port > 65535) + { + LogToConsole(string.Format(Translations.bot_WebSocketBot_failed_to_start_port, _port.ToString())); + return; + } + + _ip = Config.Ip; + _port = Config.Port; + _password = Config.Password; + _authenticatedSessions = new(); + _waitingEvents = new(); + } + + public override void Initialize() + { + Task.Run(() => + { + _authenticatedSessions.Clear(); + + if (_server != null) + { + SendEvent("OnWsRestarting", ""); + _server.Stop(); + _server = null; + } + + try + { + LogToConsole(Translations.bot_WebSocketBot_starting); + _server = new(); + _server.Start(_ip!, _port); + + LogToConsole(string.Format(Translations.bot_WebSocketBot_started, _ip, _port.ToString())); + + foreach (var (eventName, data) in _waitingEvents) + SendEvent(eventName, data); + } + catch (Exception e) + { + LogToConsole(string.Format(Translations.bot_WebSocketBot_failed_to_start_custom, e)); + return; + } + + _server.NewSession += (sender, session) => + LogToConsole(string.Format(Translations.bot_WebSocketBot_new_session, session.SessionId)); + _server.SessionDropped += (sender, session) => + LogToConsole(string.Format(Translations.bot_WebSocketBot_session_disconnected, session.SessionId)); + + _server.MessageReceived += (sender, messageObject) => + { + if (!ProcessWebsocketCommand(messageObject.SessionId, _password!, messageObject.Message)) + return; + + CmdResult response = new(); + PerformInternalCommand(messageObject.Message, ref response); + SendSessionEvent(messageObject.SessionId, "OnMccCommandResponse", $"{{\"response\": \"{response}\"}}"); + }; + }); + } + + private bool ProcessWebsocketCommand(string sessionId, string password, string message) + { + message = message.Trim(); + + if (string.IsNullOrEmpty(message)) + return false; + + if (message.StartsWith('{')) + { + try + { + if (Config.DebugMode) + LogDebugToConsole($"\n\n\tGot command\n\n\t{message}\n\n"); + + var cmd = JsonConvert.DeserializeObject(message)!; + var responder = new WsCommandResponder(this, sessionId, cmd.Command, cmd.RequestId); + + // Allow session name changing without authenticating for easier identification + if (cmd.Command.Equals("ChangeSessionId", StringComparison.OrdinalIgnoreCase)) + { + if (cmd.Parameters is not { Length: 1 }) + { + responder.SendErrorResponse( + responder.Quote("Invalid number of parameters, expected 1 (newSessionid)!"), true); + return false; + } + + var newId = (cmd.Parameters[0] as string)!; + + switch (newId.Length) + { + case 0: + responder.SendErrorResponse(responder.Quote("Please provide a valid session ID!"), + true); + return false; + case > 32: + responder.SendErrorResponse( + responder.Quote("The session ID can't be longer than 32 characters!"), true); + return false; + } + + if (!_server!.RenameSession(sessionId, newId)) + { + responder.SendErrorResponse( + responder.Quote("Failed to change the session id to: '" + newId + "'"), + true); + LogToConsole(string.Format(Translations.bot_WebSocketBot_session_id_failed_to_change, sessionId, + newId)); + return false; + } + + responder.SendSuccessResponse( + responder.Quote("The session ID was successfully changed to: '" + newId + "'"), true); + LogToConsole(string.Format(Translations.bot_WebSocketBot_session_id_changed, sessionId, newId)); + return false; + } + + // Authentication and session commands + if (password.Length != 0) + { + if (!_authenticatedSessions.Contains(sessionId)) + { + // Special case for authentication + if (cmd.Command.Equals("Authenticate", StringComparison.OrdinalIgnoreCase)) + { + if (cmd.Parameters is not { Length: 1 }) + { + responder.SendErrorResponse( + responder.Quote("Invalid number of parameters, expected 1 (password)!"), true); + return false; + } + + var pass = (cmd.Parameters[0] as string)!; + + if (pass.Length == 0) + { + responder.SendErrorResponse( + responder.Quote( + "Please provide a valid password! (Example: 'Authenticate password123')"), + true); + return false; + } + + if (!pass.Equals(password)) + { + responder.SendErrorResponse(responder.Quote("Incorrect password provided!"), true); + return false; + } + + _authenticatedSessions.Add(sessionId); + responder.SendSuccessResponse(responder.Quote("Successfully authenticated!"), true); + LogToConsole(string.Format(Translations.bot_WebSocketBot_session_authenticated, sessionId)); + return false; + } + + responder.SendErrorResponse( + responder.Quote("You must authenticate in order to send and receive data!"), true); + return false; + } + } + else + { + if (!_authenticatedSessions.Contains(sessionId)) + { + responder.SendSuccessResponse(responder.Quote("Successfully authenticated!")); + LogToConsole(string.Format(Translations.bot_WebSocketBot_session_authenticated, sessionId)); + _authenticatedSessions.Add(sessionId); + return false; + } + } + + // Process other commands + switch (cmd.Command) + { + case "LogToConsole": + if (cmd.Parameters == null || cmd.Parameters.Length > 1 || cmd.Parameters.Length < 1) + { + responder.SendErrorResponse( + responder.Quote("Invalid number of parameters, expecting a single parameter!")); + return false; + } + + LogToConsole((cmd.Parameters[0] as string)!); + responder.SendSuccessResponse(); + break; + + case "LogDebugToConsole": + if (cmd.Parameters == null || cmd.Parameters.Length > 1 || cmd.Parameters.Length < 1) + { + responder.SendErrorResponse( + responder.Quote("Invalid number of parameters, expecting a single parameter!")); + return false; + } + + LogDebugToConsole((cmd.Parameters[0] as string)!); + responder.SendSuccessResponse(); + break; + + case "LogToConsoleTranslated": + if (cmd.Parameters == null || cmd.Parameters.Length > 1 || cmd.Parameters.Length < 1) + { + responder.SendErrorResponse( + responder.Quote("Invalid number of parameters, expecting a single parameter!")); + return false; + } + + LogToConsoleTranslated((cmd.Parameters[0] as string)!); + responder.SendSuccessResponse(); + break; + + case "LogDebugToConsoleTranslated": + if (cmd.Parameters!.Length > 1 || cmd.Parameters.Length < 1) + { + responder.SendErrorResponse( + responder.Quote("Invalid number of parameters, expecting a single parameter!")); + return false; + } + + LogDebugToConsoleTranslated((cmd.Parameters[0] as string)!); + responder.SendSuccessResponse(); + break; + + case "ReconnectToTheServer": + if (cmd.Parameters is not { Length: 2 }) + { + responder.SendErrorResponse(responder.Quote( + "Invalid number of parameters, expecting 2 parameters (extraAttempts, delaySeconds)!")); + return false; + } + + ReconnectToTheServer(Convert.ToInt32(cmd.Parameters[0]), Convert.ToInt32(cmd.Parameters[1])); + responder.SendSuccessResponse(); + break; + + case "DisconnectAndExit": + responder.SendSuccessResponse(); + DisconnectAndExit(); + break; + + case "SendPrivateMessage": + if (cmd.Parameters is not { Length: 2 }) + { + responder.SendErrorResponse(responder.Quote( + "Invalid number of parameters, expecting 2 parameters (player, message)!")); + return false; + } + + SendPrivateMessage((cmd.Parameters[0] as string)!, (cmd.Parameters[1] as string)!); + responder.SendSuccessResponse(); + break; + + case "RunScript": + if (cmd.Parameters is not { Length: 1 }) + { + responder.SendErrorResponse( + responder.Quote("Invalid number of parameters, expecting 1 parameter (filename)!")); + return false; + } + + RunScript((cmd.Parameters[0] as string)!); + responder.SendSuccessResponse(); + break; + + case "GetTerrainEnabled": + responder.SendSuccessResponse(GetTerrainEnabled().ToString().ToLower()); + break; + + case "SetTerrainEnabled": + if (cmd.Parameters is not { Length: 1 }) + { + responder.SendErrorResponse( + responder.Quote("Invalid number of parameters, expecting 1 parameter (enabled)!")); + return false; + } + + SetTerrainEnabled((bool)cmd.Parameters[0]); + responder.SendSuccessResponse(); + break; + + case "GetEntityHandlingEnabled": + responder.SendSuccessResponse(GetEntityHandlingEnabled().ToString().ToLower()); + break; + + case "Sneak": + if (cmd.Parameters is not { Length: 1 }) + { + responder.SendErrorResponse( + responder.Quote("Invalid number of parameters, expecting 1 parameter (on)!")); + return false; + } + + Sneak((bool)cmd.Parameters[0]); + responder.SendSuccessResponse(); + break; + + case "SendEntityAction": + if (cmd.Parameters is not { Length: 1 }) + { + responder.SendErrorResponse( + responder.Quote("Invalid number of parameters, expecting 1 parameter (actionType)!")); + return false; + } + + SendEntityAction(((Protocol.EntityActionType)(Convert.ToInt32(cmd.Parameters[0])))); + responder.SendSuccessResponse(); + break; + + case "DigBlock": + if (cmd.Parameters == null || cmd.Parameters.Length == 0 || cmd.Parameters.Length < 3 || + cmd.Parameters.Length > 5) + { + responder.SendErrorResponse(responder.Quote( + "Invalid number of parameters, expecting 1 or 3 parameter(s) (location, swingArms?, lookAtBlock?)!")); + return false; + } + + var location = new Location(Convert.ToInt32(cmd.Parameters[0]), + Convert.ToInt32(cmd.Parameters[1]), Convert.ToInt32(cmd.Parameters[2])); + + if (location.DistanceSquared(GetCurrentLocation().EyesLocation()) > 25) + { + responder.SendErrorResponse( + responder.Quote("The block you're trying to dig is too far away!")); + return false; + } + + if (GetWorld().GetBlock(location).Type == Material.Air) + { + responder.SendErrorResponse(responder.Quote("The block you're trying to dig is is air!")); + return false; + } + + var result = cmd.Parameters.Length switch + { + 3 => DigBlock(location), + 4 => DigBlock(location, (bool)cmd.Parameters[3]), + 5 => DigBlock(location, (bool)cmd.Parameters[3], (bool)cmd.Parameters[4]), + _ => false + }; + + responder.SendSuccessResponse(JsonConvert.SerializeObject(result)); + break; + + case "SetSlot": + if (cmd.Parameters is not { Length: 1 }) + { + responder.SendErrorResponse( + responder.Quote("Invalid number of parameters, expecting 1 parameter (slotNumber)!")); + return false; + } + + SetSlot(Convert.ToInt32(cmd.Parameters[0])); + responder.SendSuccessResponse(); + break; + + case "GetWorld": + responder.SendSuccessResponse(JsonConvert.SerializeObject(GetWorld())); + break; + + case "GetEntities": + responder.SendSuccessResponse(JsonConvert.SerializeObject(GetEntities())); + break; + + case "GetPlayersLatency": + responder.SendSuccessResponse(JsonConvert.SerializeObject(GetPlayersLatency())); + break; + + case "GetCurrentLocation": + responder.SendSuccessResponse(JsonConvert.SerializeObject(GetCurrentLocation())); + break; + + case "MoveToLocation": + if (cmd.Parameters == null || cmd.Parameters.Length == 0 || cmd.Parameters.Length < 3 || + cmd.Parameters.Length > 8) + { + responder.SendErrorResponse(responder.Quote( + "Invalid number of parameters, expecting 1 or 7 parameter(s) (x, y, z, allowUnsafe?, allowDirectTeleport?, maxOffset?, minoffset?, timeout?)!")); + return false; + } + + var allowUnsafe = false; + var allowDirectTeleport = false; + var maxOffset = 0; + var minOffset = 0; + TimeSpan? timeout = null; + + if (cmd.Parameters.Length >= 4) + allowUnsafe = (bool)cmd.Parameters[3]; + + if (cmd.Parameters.Length >= 5) + allowDirectTeleport = (bool)cmd.Parameters[4]; + + if (cmd.Parameters.Length >= 6) + maxOffset = Convert.ToInt32(cmd.Parameters[5]); + + if (cmd.Parameters.Length >= 7) + minOffset = Convert.ToInt32(cmd.Parameters[6]); + + if (cmd.Parameters.Length == 8) + timeout = TimeSpan.FromSeconds(Convert.ToInt32(cmd.Parameters[7])); + + var canMove = MoveToLocation( + new Location(Convert.ToInt32(cmd.Parameters[0]), + Convert.ToInt32(cmd.Parameters[1]), + Convert.ToInt32(cmd.Parameters[2])), + allowUnsafe, + allowDirectTeleport, + maxOffset, + minOffset, + timeout); + + responder.SendSuccessResponse(JsonConvert.SerializeObject(canMove)); + break; + + case "ClientIsMoving": + responder.SendSuccessResponse(JsonConvert.SerializeObject(ClientIsMoving())); + break; + + case "LookAtLocation": + if (cmd.Parameters == null || cmd.Parameters.Length == 0 || cmd.Parameters.Length < 3 || + cmd.Parameters.Length > 3) + { + responder.SendErrorResponse( + responder.Quote("Invalid number of parameters, expecting 3 parameter(s) (x, y, z)!")); + return false; + } + + LookAtLocation(new Location(Convert.ToInt32(cmd.Parameters[0]), + Convert.ToInt32(cmd.Parameters[1]), Convert.ToInt32(cmd.Parameters[2]))); + responder.SendSuccessResponse(); + break; + + case "GetTimestamp": + responder.SendSuccessResponse(JsonConvert.SerializeObject(GetTimestamp())); + break; + + case "GetServerPort": + responder.SendSuccessResponse(JsonConvert.SerializeObject(GetServerPort())); + break; + + case "GetServerHost": + responder.SendSuccessResponse(JsonConvert.SerializeObject(GetServerHost())); + break; + + case "GetUsername": + responder.SendSuccessResponse(JsonConvert.SerializeObject(GetUsername())); + break; + + case "GetGamemode": + responder.SendSuccessResponse(JsonConvert.SerializeObject(GameModeString(GetGamemode()))); + break; + + case "GetYaw": + responder.SendSuccessResponse(JsonConvert.SerializeObject(GetYaw())); + break; + + case "GetPitch": + responder.SendSuccessResponse(JsonConvert.SerializeObject(GetPitch())); + break; + + case "GetUserUUID": + responder.SendSuccessResponse(JsonConvert.SerializeObject(GetUserUUID())); + break; + + case "GetOnlinePlayers": + responder.SendSuccessResponse(JsonConvert.SerializeObject(GetOnlinePlayers())); + break; + + case "GetOnlinePlayersWithUUID": + responder.SendSuccessResponse(JsonConvert.SerializeObject(GetOnlinePlayersWithUUID())); + break; + + case "GetServerTPS": + responder.SendSuccessResponse(JsonConvert.SerializeObject(GetServerTPS())); + break; + + case "InteractEntity": + if (cmd.Parameters == null || cmd.Parameters.Length == 0 || cmd.Parameters.Length < 2 || + cmd.Parameters.Length > 3) + { + responder.SendErrorResponse(responder.Quote( + "Invalid number of parameters, expecting at least 2 and at most 3 parameter(s) (entityId, interactionType, hand?)!")); + return false; + } + + var interactionType = (InteractType)Convert.ToInt32(cmd.Parameters[1]); + var interactionHand = Hand.MainHand; + + if (cmd.Parameters.Length == 3) + interactionHand = (Hand)Convert.ToInt32(cmd.Parameters[2]); + + responder.SendSuccessResponse(JsonConvert.SerializeObject( + InteractEntity(Convert.ToInt32(cmd.Parameters[0]), interactionType, interactionHand))); + break; + + case "CreativeGive": + if (cmd.Parameters == null || cmd.Parameters.Length == 0 || cmd.Parameters.Length < 3 || + cmd.Parameters.Length > 4) + { + responder.SendErrorResponse(responder.Quote( + "Invalid number of parameters, expecting at least 3 and at most 4 parameter(s) (slotId, itemType, count, nbt?)!")); + return false; + } + + NBT? nbt = null; + + if (cmd.Parameters.Length == 4) + nbt = JsonConvert.DeserializeObject(cmd.Parameters[3].ToString()!, + new NbtDictionaryConverter())!; + + responder.SendSuccessResponse( + JsonConvert.SerializeObject(CreativeGive( + Convert.ToInt32(cmd.Parameters[0]), + (ItemType)Convert.ToInt32(cmd.Parameters[1]), + Convert.ToInt32(cmd.Parameters[2]), + nbt == null ? new Dictionary() : nbt!.nbt!) + )); + + break; + + case "CreativeDelete": + if (cmd.Parameters is not { Length: 1 }) + { + responder.SendErrorResponse( + responder.Quote("Invalid number of parameters, expecting at 1 parameter (slotId)!")); + return false; + } + + responder.SendSuccessResponse( + JsonConvert.SerializeObject(CreativeDelete(Convert.ToInt32(cmd.Parameters[0])))); + break; + + case "SendAnimation": + var hand = Hand.MainHand; + + if (cmd.Parameters is { Length: 1 }) + hand = (Hand)Convert.ToInt32(cmd.Parameters[0]); + + responder.SendSuccessResponse(JsonConvert.SerializeObject(SendAnimation(hand))); + break; + + case "SendPlaceBlock": + if (cmd.Parameters == null || cmd.Parameters.Length == 0 || cmd.Parameters.Length < 4 || + cmd.Parameters.Length > 4) + { + responder.SendErrorResponse(responder.Quote( + "Invalid number of parameters, expecting at least 4 and at most 5 parameters (x, y, z, blockFace, hand?)!")); + return false; + } + + var blockLocation = new Location(Convert.ToInt32(cmd.Parameters[0]), + Convert.ToInt32(cmd.Parameters[1]), Convert.ToInt32(cmd.Parameters[2])); + var blockFacingDirection = (Direction)Convert.ToInt32(cmd.Parameters[3]); + var handToUse = Hand.MainHand; + + if (cmd.Parameters.Length == 4) + handToUse = (Hand)Convert.ToInt32(cmd.Parameters[4]); + + responder.SendSuccessResponse( + JsonConvert.SerializeObject(SendPlaceBlock(blockLocation, blockFacingDirection, + handToUse))); + break; + + case "UseItemInHand": + responder.SendSuccessResponse(JsonConvert.SerializeObject(UseItemInHand())); + break; + + case "GetInventoryEnabled": + responder.SendSuccessResponse(JsonConvert.SerializeObject(GetInventoryEnabled())); + break; + + case "GetPlayerInventory": + responder.SendSuccessResponse(JsonConvert.SerializeObject(GetPlayerInventory())); + break; + + case "GetInventories": + responder.SendSuccessResponse(JsonConvert.SerializeObject(GetInventories())); + break; + + case "WindowAction": + if (cmd.Parameters == null || cmd.Parameters.Length == 0 || cmd.Parameters.Length != 3) + { + responder.SendErrorResponse(responder.Quote( + "Invalid number of parameters, expecting 3 parameters (inventoryId, slotId, windowActionType)!")); + return false; + } + + responder.SendSuccessResponse( + JsonConvert.SerializeObject(WindowAction( + Convert.ToInt32(cmd.Parameters[0]), + Convert.ToInt32(cmd.Parameters[1]), + (WindowActionType)Convert.ToInt32(cmd.Parameters[2]) + ))); + break; + + case "ChangeSlot": + if (cmd.Parameters is not { Length: 1 }) + { + responder.SendErrorResponse( + responder.Quote("Invalid number of parameters, expecting 1 parameter (slotId)!")); + return false; + } + + responder.SendSuccessResponse( + JsonConvert.SerializeObject(ChangeSlot((short)Convert.ToInt32(cmd.Parameters[0])))); + break; + + case "GetCurrentSlot": + responder.SendSuccessResponse(JsonConvert.SerializeObject(GetCurrentSlot())); + break; + + case "ClearInventories": + responder.SendSuccessResponse(JsonConvert.SerializeObject(ClearInventories())); + break; + + case "UpdateSign": + if (cmd.Parameters is not { Length: 7 }) + { + responder.SendErrorResponse(responder.Quote( + "Invalid number of parameters, expecting 1 parameter (x, y, z, line1, line2, line3, line4)!")); + return false; + } + + var signLocation = new Location(Convert.ToInt32(cmd.Parameters[0]), + Convert.ToInt32(cmd.Parameters[1]), Convert.ToInt32(cmd.Parameters[2])); + + responder.SendSuccessResponse( + JsonConvert.SerializeObject(UpdateSign(signLocation, + (string)cmd.Parameters[3], + (string)cmd.Parameters[4], + (string)cmd.Parameters[5], + (string)cmd.Parameters[6] + ))); + break; + + case "SelectTrade": + if (cmd.Parameters is not { Length: 1 }) + { + responder.SendErrorResponse( + responder.Quote("Invalid number of parameters, expecting 1 parameter (selectedSlot)!")); + return false; + } + + responder.SendSuccessResponse( + JsonConvert.SerializeObject(SelectTrade(Convert.ToInt32(cmd.Parameters[0])))); + break; + + case "UpdateCommandBlock": + if (cmd.Parameters is not { Length: 6 }) + { + responder.SendErrorResponse(responder.Quote( + "Invalid number of parameters, expecting 1 parameter (x, y, z, command, commandBlockMode, commandBlockFlags)!")); + return false; + } + + var commandBlockLocation = new Location(Convert.ToInt32(cmd.Parameters[0]), + Convert.ToInt32(cmd.Parameters[1]), Convert.ToInt32(cmd.Parameters[2])); + + responder.SendSuccessResponse( + UpdateCommandBlock(commandBlockLocation, + (string)cmd.Parameters[3], + (CommandBlockMode)Convert.ToInt32(cmd.Parameters[4]), + (CommandBlockFlags)Convert.ToInt32(cmd.Parameters[5]) + ).ToString().ToLower()); + break; + + case "CloseInventory": + if (cmd.Parameters is not { Length: 1 }) + { + responder.SendErrorResponse( + responder.Quote("Invalid number of parameters, expecting 1 parameter (inventoryId)!")); + return false; + } + + responder.SendSuccessResponse(CloseInventory(Convert.ToInt32(cmd.Parameters[0])).ToString() + .ToLower()); + break; + + case "GetMaxChatMessageLength": + responder.SendSuccessResponse(JsonConvert.SerializeObject(GetMaxChatMessageLength())); + break; + + case "Respawn": + responder.SendSuccessResponse(JsonConvert.SerializeObject(Respawn())); + break; + + case "GetProtocolVersion": + responder.SendSuccessResponse(JsonConvert.SerializeObject(GetProtocolVersion())); + break; + + default: + responder.SendErrorResponse( + responder.Quote($"Unknown command {cmd.Command} received!")); + break; + } + } + catch (Exception e) + { + LogDebugToConsole(e.Message); + SendSessionEvent(sessionId, "OnWsCommandResponse", + "{\"success\": false, \"message\": \"An error occured, possible reasons: mail-formed json, type conversion, internal error\", \"stackTrace\": \"" + + Json.EscapeString(e.ToString()) + "\"}", true); + return false; + } + + return false; + } + + if (password.Length != 0) + { + if (!_authenticatedSessions.Contains(sessionId)) + { + SendSessionEvent(sessionId, "OnWsCommandResponse", + "{\"error\": true, \"message\": \"You must authenticate in order to send and receive data!\"}", + true); + return false; + } + } + else + { + if (!_authenticatedSessions.Contains(sessionId)) + { + SendSessionEvent(sessionId, "OnWsCommandResponse", + "{\"success\": true, \"message\": \"Successfully authenticated!\"}", true); + LogToConsole(string.Format(Translations.bot_WebSocketBot_session_authenticated, sessionId)); + _authenticatedSessions.Add(sessionId); + } + } + + SendText(message); + return true; + } + + public override void OnUnload() + { + if (_server != null) + { + SendEvent("OnWsConnectionClose", ""); + _server.Stop(); + _server = null; + } + + _authenticatedSessions.Clear(); + } + + // ========================================================================================== + // Bot Events + // ========================================================================================== + public override void AfterGameJoined() + { + // Workaround to wait until the WebSocket server has been started + // This would fire before the WS server is started, this causing a null exception. + _waitingEvents.Add(("OnGameJoined", "")); + } + + public override void OnBlockBreakAnimation(Entity entity, Location location, byte stage) + { + SendEvent("OnBlockBreakAnimation", new { entity, location, stage }); + } + + public override void OnEntityAnimation(Entity entity, byte animation) + { + SendEvent("OnEntityAnimation", new { entity, animation }); + } + + public override void GetText(string text) + { + text = GetVerbatim(text).Trim(); + + var message = ""; + var username = ""; + + if (IsPrivateMessage(text, ref message, ref username)) + SendEvent("OnChatPrivate", new { sender = username, message, rawText = text }); + else if (IsChatMessage(text, ref message, ref username)) + SendEvent("OnChatPublic", new { username, message, rawText = text }); + else if (IsTeleportRequest(text, ref username)) + SendEvent("OnTeleportRequest", new { sender = username, rawText = text }); + } + + public override void GetText(string text, string? json) + { + SendEvent("OnChatRaw", new { text, json }); + } + + public override bool OnDisconnect(DisconnectReason reason, string message) + { + var reasonString = reason switch + { + DisconnectReason.ConnectionLost => "Connection Lost", + DisconnectReason.UserLogout => "User Logout", + DisconnectReason.InGameKick => "In-Game Kick", + DisconnectReason.LoginRejected => "Login Rejected", + _ => "Unknown" + }; + + SendEvent("OnDisconnect", new { reason = reasonString, message }); + return false; + } + + public override void OnPlayerProperty(Dictionary prop) + { + SendEvent("OnPlayerProperty", prop); + } + + public override void OnServerTpsUpdate(double tps) + { + SendEvent("OnServerTpsUpdate", new { tps }); + } + + public override void OnTimeUpdate(long worldAge, long timeOfDay) + { + SendEvent("OnTimeUpdate", new { worldAge, timeOfDay }); + } + + public override void OnEntityMove(Entity entity) + { + SendEvent("OnEntityMove", entity); + } + + public override void OnInternalCommand(string commandName, string commandParams, CmdResult result) + { + SendEvent("OnInternalCommand", + new { command = commandName, parameters = commandParams, result = result.ToString().Replace("\"", "'") }); + } + + public override void OnEntitySpawn(Entity entity) + { + SendEvent("OnEntitySpawn", entity); + } + + public override void OnEntityDespawn(Entity entity) + { + SendEvent("OnEntityDespawn", entity); + } + + public override void OnHeldItemChange(byte slot) + { + SendEvent("OnHeldItemChange", new { itemSlot = slot }); + } + + public override void OnHealthUpdate(float health, int food) + { + SendEvent("OnHealthUpdate", new { health, food }); + } + + public override void OnExplosion(Location explode, float strength, int recordCount) + { + SendEvent("OnExplosion", new { location = explode, strength, recordCount }); + } + + public override void OnSetExperience(float experienceBar, int level, int totalExperience) + { + SendEvent("OnSetExperience", + new { experienceBar, level, totalExperience }); + } + + public override void OnGamemodeUpdate(string playerName, Guid uuid, int gameMode) + { + SendEvent("OnGamemodeUpdate", new { playerName, uuid, gameMode = GameModeString(gameMode) }); + } + + public override void OnLatencyUpdate(string playerName, Guid uuid, int latency) + { + SendEvent("OnLatencyUpdate", new { playerName, uuid, latency }); + } + + public override void OnMapData(int mapId, byte scale, bool trackingPosition, bool locked, List icons, + byte columnsUpdated, byte rowsUpdated, byte mapColumnX, byte mapRowZ, byte[]? colors) + { + SendEvent("OnMapData", + new + { + mapId, scale, trackingPosition, locked, icons, columnsUpdated, rowsUpdated, mapColumnX, mapRowZ, + colors + }); + } + + public override void OnTradeList(int windowId, List trades, VillagerInfo villagerInfo) + { + SendEvent("OnTradeList", new { windowId, trades, villagerInfo }); + } + + public override void OnTitle(int action, string titleText, string subtitleText, string actionBarText, int fadein, + int stay, int fadeout, string json_) + { + SendEvent("OnTitle", + new + { + action, titleText, subtitleText, actionBarText, + fadeIn = fadein, stay, rawJson = json_ + }); + } + + public override void OnEntityEquipment(Entity entity, int slot, Item? item) + { + SendEvent("OnEntityEquipment", new { entity, slot, item }); + } + + public override void OnEntityEffect(Entity entity, Effects effect, int amplifier, int duration, byte flags) + { + SendEvent("OnEntityEffect", new { entity, effect, amplifier, duration, flags }); + } + + public override void OnScoreboardObjective(string objectiveName, byte mode, string objectiveValue, int type, + string json_) + { + SendEvent("OnScoreboardObjective", + new { objectiveName, mode, objectiveValue, type, rawJson = json_ }); + } + + public override void OnUpdateScore(string entityName, int action, string objectiveName, int value) + { + SendEvent("OnUpdateScore", + new { entityName, action, objectiveName, type = value }); + } + + public override void OnInventoryUpdate(int inventoryId) + { + SendEvent("OnInventoryUpdate", new { inventoryId }); + } + + public override void OnInventoryOpen(int inventoryId) + { + SendEvent("OnInventoryOpen", new { inventoryId }); + } + + public override void OnInventoryClose(int inventoryId) + { + SendEvent("OnInventoryClose", new { inventoryId }); + } + + public override void OnPlayerJoin(Guid uuid, string name) + { + SendEvent("OnPlayerJoin", new { uuid, name }); + } + + public override void OnPlayerLeave(Guid uuid, string? name) + { + SendEvent("OnPlayerLeave", new { uuid, name = name ?? "null" }); + } + + public override void OnDeath() + { + SendEvent("OnDeath", ""); + } + + public override void OnRespawn() + { + SendEvent("OnRespawn", ""); + } + + public override void OnEntityHealth(Entity entity, float health) + { + SendEvent("OnEntityHealth", new { entity, health }); + } + + public override void OnEntityMetadata(Entity entity, Dictionary? metadata) + { + SendEvent("OnEntityMetadata", new { entity, metadata }); + } + + public override void OnPlayerStatus(byte statusId) + { + SendEvent("OnPlayerStatus", new { statusId }); + } + + public override void OnNetworkPacket(int packetID, List packetData, bool isLogin, bool isInbound) + { + SendEvent("OnNetworkPacket", new { packetId = packetID, isLogin, isInbound, packetData }); + } + + // ========================================================================================== + // Helper methods + // ========================================================================================== + + private void SendEvent(string type, object data, bool overrideAuth = false) + { + if (_server == null) + return; + + foreach (var (sessionId, _) in _server!.Sessions) + SendSessionEvent(sessionId, type, JsonConvert.SerializeObject(data), overrideAuth); + } + + private void SendEvent(string type, string data, bool overrideAuth = false) + { + if (_server == null) + return; + + foreach (var (sessionId, _) in _server.Sessions) + SendSessionEvent(sessionId, type, data, overrideAuth); + } + + private void SendSessionEvent(string sessionId, string type, string data, bool overrideAuth = false) + { + if (sessionId.Length > 0 && (overrideAuth || _authenticatedSessions.Contains(sessionId))) + { + _server?.SendToSession(sessionId, + $"{{\"event\": \"{type}\", \"data\": {(string.IsNullOrEmpty(data) ? "null" : $"\"{Json.EscapeString(data)}\"")}}}") + .Wait(); + + if (!(type.Contains("Entity") || type.Equals("OnTimeUpdate") || type.Equals("OnServerTpsUpdate")) && + Config.DebugMode) + LogDebugToConsole( + $"\n\n\tSending:\n\n\t{{\"event\": \"{type}\", \"data\": {(string.IsNullOrEmpty(data) + ? "null" + : $"\"{Json.EscapeString(data)}\"")}}}\n\n"); + } + } + + public void SendCommandResponse(string sessionId, bool success, string requestId, string command, + string result, bool overrideAuth = false) + { + SendSessionEvent(sessionId, "OnWsCommandResponse", + $"{{\"success\": {success.ToString().ToLower()}, \"requestId\": \"{requestId}\", \"command\": \"{command}\", \"result\": {(string.IsNullOrEmpty(result) ? "null" : result)}}}", + overrideAuth); + } + + private static string GameModeString(int gameMode) + { + return gameMode switch + { + 0 => "survival", + 1 => "creative", + 2 => "adventure", + 3 => "spectator", + _ => "unknown" + }; + } +} \ No newline at end of file diff --git a/MinecraftClient/Json.cs b/MinecraftClient/Json.cs index be70b48e17..494e114203 100644 --- a/MinecraftClient/Json.cs +++ b/MinecraftClient/Json.cs @@ -25,12 +25,24 @@ public static JSONData ParseJson(string json) /// public class JSONData { - public enum DataType { Object, Array, String }; + public enum DataType + { + Object, + Array, + String + }; + private readonly DataType type; - public DataType Type { get { return type; } } + + public DataType Type + { + get { return type; } + } + public Dictionary Properties; public List DataArray; public string StringValue; + public JSONData(DataType datatype) { type = datatype; @@ -63,12 +75,21 @@ private static JSONData String2Data(string toparse, ref int cursorpos) if (toparse[cursorpos] == '"') { JSONData propertyname = String2Data(toparse, ref cursorpos); - if (toparse[cursorpos] == ':') { cursorpos++; } else { /* parse error ? */ } + if (toparse[cursorpos] == ':') + { + cursorpos++; + } + else + { + /* parse error ? */ + } + JSONData propertyData = String2Data(toparse, ref cursorpos); data.Properties[propertyname.StringValue] = propertyData; } else cursorpos++; } + cursorpos++; break; @@ -79,10 +100,15 @@ private static JSONData String2Data(string toparse, ref int cursorpos) SkipSpaces(toparse, ref cursorpos); while (toparse[cursorpos] != ']') { - if (toparse[cursorpos] == ',') { cursorpos++; } + if (toparse[cursorpos] == ',') + { + cursorpos++; + } + JSONData arrayItem = String2Data(toparse, ref cursorpos); data.DataArray.Add(arrayItem); } + cursorpos++; break; @@ -103,9 +129,11 @@ private static JSONData String2Data(string toparse, ref int cursorpos) && IsHex(toparse[cursorpos + 5])) { //"abc\u0123abc" => "0123" => 0123 => Unicode char n°0123 => Add char to string - data.StringValue += char.ConvertFromUtf32(int.Parse(toparse.Substring(cursorpos + 2, 4), + data.StringValue += char.ConvertFromUtf32(int.Parse( + toparse.Substring(cursorpos + 2, 4), System.Globalization.NumberStyles.HexNumber)); - cursorpos += 6; continue; + cursorpos += 6; + continue; } else if (toparse[cursorpos + 1] == 'n') { @@ -127,12 +155,20 @@ private static JSONData String2Data(string toparse, ref int cursorpos) } else cursorpos++; //Normal character escapement \" } - catch (IndexOutOfRangeException) { cursorpos++; } // \u01 - catch (ArgumentOutOfRangeException) { cursorpos++; } // Unicode index 0123 was invalid + catch (IndexOutOfRangeException) + { + cursorpos++; + } // \u01 + catch (ArgumentOutOfRangeException) + { + cursorpos++; + } // Unicode index 0123 was invalid } + data.StringValue += toparse[cursorpos]; cursorpos++; } + cursorpos++; break; @@ -151,11 +187,13 @@ private static JSONData String2Data(string toparse, ref int cursorpos) case '-': data = new JSONData(JSONData.DataType.String); StringBuilder sb = new(); - while ((toparse[cursorpos] >= '0' && toparse[cursorpos] <= '9') || toparse[cursorpos] == '.' || toparse[cursorpos] == '-') + while ((toparse[cursorpos] >= '0' && toparse[cursorpos] <= '9') || toparse[cursorpos] == '.' || + toparse[cursorpos] == '-') { sb.Append(toparse[cursorpos]); cursorpos++; } + data.StringValue = sb.ToString(); break; @@ -163,28 +201,71 @@ private static JSONData String2Data(string toparse, ref int cursorpos) case 't': data = new JSONData(JSONData.DataType.String); cursorpos++; - if (toparse[cursorpos] == 'r') { cursorpos++; } - if (toparse[cursorpos] == 'u') { cursorpos++; } - if (toparse[cursorpos] == 'e') { cursorpos++; data.StringValue = "true"; } + if (toparse[cursorpos] == 'r') + { + cursorpos++; + } + + if (toparse[cursorpos] == 'u') + { + cursorpos++; + } + + if (toparse[cursorpos] == 'e') + { + cursorpos++; + data.StringValue = "true"; + } + break; //Boolean : false case 'f': data = new JSONData(JSONData.DataType.String); cursorpos++; - if (toparse[cursorpos] == 'a') { cursorpos++; } - if (toparse[cursorpos] == 'l') { cursorpos++; } - if (toparse[cursorpos] == 's') { cursorpos++; } - if (toparse[cursorpos] == 'e') { cursorpos++; data.StringValue = "false"; } + if (toparse[cursorpos] == 'a') + { + cursorpos++; + } + + if (toparse[cursorpos] == 'l') + { + cursorpos++; + } + + if (toparse[cursorpos] == 's') + { + cursorpos++; + } + + if (toparse[cursorpos] == 'e') + { + cursorpos++; + data.StringValue = "false"; + } + break; //Null field case 'n': data = new JSONData(JSONData.DataType.String); cursorpos++; - if (toparse[cursorpos] == 'u') { cursorpos++; } - if (toparse[cursorpos] == 'l') { cursorpos++; } - if (toparse[cursorpos] == 'l') { cursorpos++; data.StringValue = "null"; } + if (toparse[cursorpos] == 'u') + { + cursorpos++; + } + + if (toparse[cursorpos] == 'l') + { + cursorpos++; + } + + if (toparse[cursorpos] == 'l') + { + cursorpos++; + data.StringValue = "null"; + } + break; //Unknown data @@ -192,6 +273,7 @@ private static JSONData String2Data(string toparse, ref int cursorpos) cursorpos++; return String2Data(toparse, ref cursorpos); } + SkipSpaces(toparse, ref cursorpos); return data; } @@ -206,7 +288,10 @@ private static JSONData String2Data(string toparse, ref int cursorpos) /// /// Char to test /// True if hexadecimal - private static bool IsHex(char c) { return ((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f')); } + private static bool IsHex(char c) + { + return ((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f')); + } /// /// Advance the cursor to skip white spaces and line breaks @@ -216,10 +301,77 @@ private static JSONData String2Data(string toparse, ref int cursorpos) private static void SkipSpaces(string toparse, ref int cursorpos) { while (cursorpos < toparse.Length - && (char.IsWhiteSpace(toparse[cursorpos]) - || toparse[cursorpos] == '\r' - || toparse[cursorpos] == '\n')) + && (char.IsWhiteSpace(toparse[cursorpos]) + || toparse[cursorpos] == '\r' + || toparse[cursorpos] == '\n')) cursorpos++; } + + // Original: https://github.com/mono/mono/blob/master/mcs/class/System.Json/System.Json/JsonValue.cs + private static bool NeedEscape(string src, int i) + { + var c = src[i]; + return c < 32 || c == '"' || c == '\\' + // Broken lead surrogate + || (c is >= '\uD800' and <= '\uDBFF' && + (i == src.Length - 1 || src[i + 1] < '\uDC00' || src[i + 1] > '\uDFFF')) + // Broken tail surrogate + || (c is >= '\uDC00' and <= '\uDFFF' && + (i == 0 || src[i - 1] < '\uD800' || src[i - 1] > '\uDBFF')) + // To produce valid JavaScript + || c == '\u2028' || c == '\u2029' + // Escape " tags + || (c == '/' && i > 0 && src[i - 1] == '<'); + } + + public static string EscapeString(string src) + { + var sb = new StringBuilder(); + var start = 0; + + for (var i = 0; i < src.Length; i++) + { + if (!NeedEscape(src, i)) continue; + sb.Append(src, start, i - start); + + switch (src[i]) + { + case '\b': + sb.Append("\\b"); + break; + case '\f': + sb.Append("\\f"); + break; + case '\n': + sb.Append("\\n"); + break; + case '\r': + sb.Append("\\r"); + break; + case '\t': + sb.Append("\\t"); + break; + case '\"': + sb.Append("\\\""); + break; + case '\\': + sb.Append("\\\\"); + break; + case '/': + sb.Append("\\/"); + break; + + default: + sb.Append("\\u"); + sb.Append(((int)src[i]).ToString("x04")); + break; + } + + start = i + 1; + } + + sb.Append(src, start, src.Length - start); + return sb.ToString(); + } } -} +} \ No newline at end of file diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs index a970faeab9..e8e0d8e983 100644 --- a/MinecraftClient/McClient.cs +++ b/MinecraftClient/McClient.cs @@ -297,6 +297,7 @@ private void RegisterBots(bool reload = false) if (Config.ChatBot.ScriptScheduler.Enabled) { BotLoad(new ScriptScheduler()); } if (Config.ChatBot.TelegramBridge.Enabled) { BotLoad(new TelegramBridge()); } if (Config.ChatBot.ItemsCollector.Enabled) { BotLoad(new ItemsCollector()); } + if (Config.ChatBot.WebSocketBot.Enabled) { BotLoad(new WebSocketBot()); } //Add your ChatBot here by uncommenting and adapting //BotLoad(new ChatBots.YourBot()); } diff --git a/MinecraftClient/Resources/ConfigComments/ConfigComments.Designer.cs b/MinecraftClient/Resources/ConfigComments/ConfigComments.Designer.cs index b82cbc9262..aad4ac14bc 100644 --- a/MinecraftClient/Resources/ConfigComments/ConfigComments.Designer.cs +++ b/MinecraftClient/Resources/ConfigComments/ConfigComments.Designer.cs @@ -1238,5 +1238,35 @@ internal static string ChatBot_ItemsCollector { return ResourceManager.GetString("ChatBot.ItemsCollector", resourceCulture); } } + + internal static string ChatBot_WebSocketBot { + get { + return ResourceManager.GetString("ChatBot.WebSocketBot", resourceCulture); + } + } + + internal static string ChatBot_WebSocketBot_Ip { + get { + return ResourceManager.GetString("ChatBot.WebSocketBot.Ip", resourceCulture); + } + } + + internal static string ChatBot_WebSocketBot_Port { + get { + return ResourceManager.GetString("ChatBot.WebSocketBot.Port", resourceCulture); + } + } + + internal static string ChatBot_WebSocketBot_Password { + get { + return ResourceManager.GetString("ChatBot.WebSocketBot.Password", resourceCulture); + } + } + + internal static string ChatBot_WebSocketBot_DebugMode { + get { + return ResourceManager.GetString("ChatBot.WebSocketBot.DebugMode", resourceCulture); + } + } } } diff --git a/MinecraftClient/Resources/ConfigComments/ConfigComments.resx b/MinecraftClient/Resources/ConfigComments/ConfigComments.resx index 00c12f9719..dca7b0fa61 100644 --- a/MinecraftClient/Resources/ConfigComments/ConfigComments.resx +++ b/MinecraftClient/Resources/ConfigComments/ConfigComments.resx @@ -828,4 +828,19 @@ If the connection to the Minecraft game server is blocked by the firewall, set E A Chat Bot that collects items on the ground + + Remotely control the client using Web Sockets.\n# This is useful if you want to implement an application that can remotely and asynchronously execute procedures in MCC.\n# Example implementation written in JavaScript: https://github.com/milutinke/MCC.js.git\n# The protocol specification will be available in the documentation soon. + + + The IP address that Websocket server will be bound to. + + + The Port that Websocket server will be bounded to. + + + A password that will be used to authenticate on thw Websocket server (It is recommended to change the default password and to set a strong one). + + + This setting is for developers who are developing a library that uses this chat bot to remotely execute procedures/commands/functions. + \ No newline at end of file diff --git a/MinecraftClient/Resources/Translations/Translations.Designer.cs b/MinecraftClient/Resources/Translations/Translations.Designer.cs index 9476805008..a8c0fbaad2 100644 --- a/MinecraftClient/Resources/Translations/Translations.Designer.cs +++ b/MinecraftClient/Resources/Translations/Translations.Designer.cs @@ -3854,5 +3854,71 @@ internal static string cmd_items_collector_stopped { return ResourceManager.GetString("cmd.items.collector.stopped", resourceCulture); } } + + internal static string bot_WebSocketBot_failed_to_start_ip { + get { + return ResourceManager.GetString("bot.WebSocketBot.failed_to_start.ip", resourceCulture); + } + } + + internal static string bot_WebSocketBot_failed_to_start_port { + get { + return ResourceManager.GetString("bot.WebSocketBot.failed_to_start.port", resourceCulture); + } + } + + internal static string ChatBot_WebSocketBot_DebugMode { + get { + return ResourceManager.GetString("ChatBot.WebSocketBot.DebugMode", resourceCulture); + } + } + + internal static string bot_WebSocketBot_starting { + get { + return ResourceManager.GetString("bot.WebSocketBot.starting", resourceCulture); + } + } + + internal static string bot_WebSocketBot_started { + get { + return ResourceManager.GetString("bot.WebSocketBot.started", resourceCulture); + } + } + + internal static string bot_WebSocketBot_failed_to_start_custom { + get { + return ResourceManager.GetString("bot.WebSocketBot.failed_to_start.custom", resourceCulture); + } + } + + internal static string bot_WebSocketBot_new_session { + get { + return ResourceManager.GetString("bot.WebSocketBot.new_session", resourceCulture); + } + } + + internal static string bot_WebSocketBot_session_disconnected { + get { + return ResourceManager.GetString("bot.WebSocketBot.session_disconnected", resourceCulture); + } + } + + internal static string bot_WebSocketBot_session_id_changed { + get { + return ResourceManager.GetString("bot.WebSocketBot.session_id_changed", resourceCulture); + } + } + + internal static string bot_WebSocketBot_session_id_failed_to_change { + get { + return ResourceManager.GetString("bot.WebSocketBot.session_id_failed_to_change", resourceCulture); + } + } + + internal static string bot_WebSocketBot_session_authenticated { + get { + return ResourceManager.GetString("bot.WebSocketBot.session_authenticated", resourceCulture); + } + } } } diff --git a/MinecraftClient/Resources/Translations/Translations.resx b/MinecraftClient/Resources/Translations/Translations.resx index db1a75f7b3..c6eb236eb0 100644 --- a/MinecraftClient/Resources/Translations/Translations.resx +++ b/MinecraftClient/Resources/Translations/Translations.resx @@ -2058,4 +2058,37 @@ Logging in... Stopped collecting items. + + §cFailed to start a server! The provided IP address is not a valid one! + + + §cFailed to start a server! The provided port number {0} is out of the range, it must be 65535 or bellow it! + + + + + + Starting the Websocket server... + + + §bServer started on ip §a{0}§b port: §a{1} + + + §cFailed to start a server:\n\n{0}\n\n + + + §bNew session connected: §a{0} + + + §bSession with an id §a{0}§b has disconnected! + + + §bSession with an id §a{0}§b has been renamed to: §a{1}§b! + + + §cSession with an id §a{0}§b failed to chage the ID to: §c{1}§b! + + + §bSession with an id §a{0}§b has been succesfully authenticated! + \ No newline at end of file diff --git a/MinecraftClient/Scripting/ChatBot.cs b/MinecraftClient/Scripting/ChatBot.cs index f8ca97cd18..63b386e3e6 100644 --- a/MinecraftClient/Scripting/ChatBot.cs +++ b/MinecraftClient/Scripting/ChatBot.cs @@ -1050,7 +1050,7 @@ protected bool Sneak(bool on) /// /// Send Entity Action /// - private bool SendEntityAction(Protocol.EntityActionType entityAction) + protected bool SendEntityAction(Protocol.EntityActionType entityAction) { return Handler.SendEntityAction(entityAction); } diff --git a/MinecraftClient/Settings.cs b/MinecraftClient/Settings.cs index 1186a22863..a89c7f2305 100644 --- a/MinecraftClient/Settings.cs +++ b/MinecraftClient/Settings.cs @@ -1404,6 +1404,13 @@ public ChatBots.ItemsCollector.Configs ItemsCollector get { return ChatBots.ItemsCollector.Config; } set { ChatBots.ItemsCollector.Config = value; ChatBots.ItemsCollector.Config.OnSettingUpdate(); } } + + [TomlPrecedingComment("$ChatBot.WebSocketBot$")] + public ChatBots.WebSocketBot.Configs WebSocketBot + { + get { return ChatBots.WebSocketBot.Config!; } + set { ChatBots.WebSocketBot.Config = value; } + } } } From 95f6c5768d33f97021b08b4c0fd86bd88dd15a9c Mon Sep 17 00:00:00 2001 From: Anon Date: Sun, 28 May 2023 15:15:43 +0200 Subject: [PATCH 2/2] Fixed session renaming not working, fixed command handling --- MinecraftClient/ChatBots/WebSocketBot.cs | 84 ++++++++++++++++-------- 1 file changed, 56 insertions(+), 28 deletions(-) diff --git a/MinecraftClient/ChatBots/WebSocketBot.cs b/MinecraftClient/ChatBots/WebSocketBot.cs index b4e7df6435..cb19e0e803 100644 --- a/MinecraftClient/ChatBots/WebSocketBot.cs +++ b/MinecraftClient/ChatBots/WebSocketBot.cs @@ -41,9 +41,21 @@ public MessageReceivedEventArgs(string sessionId, string message) } } +internal class WebSocketSession +{ + public string SessionId { get; set; } + public WebSocket WebSocket { get; set; } + + public WebSocketSession(string sessionId, WebSocket webSocket) + { + SessionId = sessionId; + WebSocket = webSocket; + } +} + internal class WebSocketServer { - public readonly ConcurrentDictionary Sessions; + public readonly ConcurrentDictionary Sessions; public event EventHandler? NewSession; public event EventHandler? SessionDropped; public event EventHandler? MessageReceived; @@ -52,7 +64,7 @@ internal class WebSocketServer public WebSocketServer() { - Sessions = new ConcurrentDictionary(); + Sessions = new ConcurrentDictionary(); } public async Task Start(string ipAddress, int port) @@ -69,9 +81,11 @@ public async Task Start(string ipAddress, int port) var sessionGuid = Guid.NewGuid().ToString(); var webSocketContext = await context.AcceptWebSocketAsync(null); var webSocket = webSocketContext.WebSocket; - Sessions.TryAdd(sessionGuid, webSocket); + var webSocketSession = new WebSocketSession(sessionGuid, webSocket); + NewSession?.Invoke(this, new SessionEventArgs(sessionGuid)); - _ = ProcessWebSocketSession(sessionGuid, webSocket); + Sessions.TryAdd(sessionGuid, webSocketSession); + _ = ProcessWebSocketSession(webSocketSession); } else { @@ -85,7 +99,7 @@ public async Task Stop() { foreach (var session in Sessions) { - await session.Value.CloseAsync(WebSocketCloseStatus.NormalClosure, "Server shutting down", + await session.Value.WebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Server shutting down", CancellationToken.None); } @@ -93,25 +107,28 @@ await session.Value.CloseAsync(WebSocketCloseStatus.NormalClosure, "Server shutt listener?.Stop(); } - private async Task ProcessWebSocketSession(string sessionId, WebSocket webSocket) + private async Task ProcessWebSocketSession(WebSocketSession webSocketSession) { var buffer = new byte[1024]; try { - while (webSocket.State == WebSocketState.Open) + while (webSocketSession.WebSocket.State == WebSocketState.Open) { var receiveResult = - await webSocket.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); + await webSocketSession.WebSocket.ReceiveAsync(new ArraySegment(buffer), + CancellationToken.None); if (receiveResult.MessageType == WebSocketMessageType.Text) { var message = Encoding.UTF8.GetString(buffer, 0, receiveResult.Count); - MessageReceived?.Invoke(this, new MessageReceivedEventArgs(sessionId, message)); + MessageReceived?.Invoke(this, new MessageReceivedEventArgs(webSocketSession.SessionId, message)); } else if (receiveResult.MessageType == WebSocketMessageType.Close) { - await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Connection closed by the client", + await webSocketSession.WebSocket.CloseAsync( + WebSocketCloseStatus.NormalClosure, + "Connection closed by the client", CancellationToken.None); break; } @@ -119,8 +136,8 @@ await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Connection close } finally { - Sessions.TryRemove(sessionId, out _); - SessionDropped?.Invoke(this, new SessionEventArgs(sessionId)); + Sessions.TryRemove(webSocketSession.SessionId, out _); + SessionDropped?.Invoke(this, new SessionEventArgs(webSocketSession.SessionId)); } } @@ -129,17 +146,18 @@ public bool RenameSession(string oldSessionId, string newSessionId) if (!Sessions.ContainsKey(oldSessionId) || Sessions.ContainsKey(newSessionId)) return false; - if (!Sessions.TryRemove(oldSessionId, out var webSocket)) + if (!Sessions.TryRemove(oldSessionId, out var webSocketSession)) return false; - if (Sessions.TryAdd(newSessionId, webSocket)) + webSocketSession.SessionId = newSessionId; + + if (Sessions.TryAdd(newSessionId, webSocketSession)) return true; - if (!Sessions.TryAdd(oldSessionId, webSocket)) - { - // handle the rare case when adding back the old session fails + webSocketSession.SessionId = oldSessionId; + + if (!Sessions.TryAdd(oldSessionId, webSocketSession)) throw new Exception("Failed to add back the old session after failed rename"); - } return false; } @@ -148,10 +166,11 @@ public async Task SendToSession(string sessionId, string message) { try { - if (Sessions.TryGetValue(sessionId, out var webSocket)) + if (Sessions.TryGetValue(sessionId, out var webSocketSession)) { var buffer = Encoding.UTF8.GetBytes(message); - await webSocket.SendAsync(new ArraySegment(buffer), WebSocketMessageType.Text, true, + await webSocketSession.WebSocket.SendAsync(new ArraySegment(buffer), WebSocketMessageType.Text, + true, CancellationToken.None); } } @@ -302,7 +321,7 @@ public override void Initialize() if (_server != null) { SendEvent("OnWsRestarting", ""); - _server.Stop(); + _server.Stop(); // If you await, this will freeze the task and the websocket won't work _server = null; } @@ -310,7 +329,7 @@ public override void Initialize() { LogToConsole(Translations.bot_WebSocketBot_starting); _server = new(); - _server.Start(_ip!, _port); + _server.Start(_ip!, _port); // If you await, this will freeze the task and the websocket won't work LogToConsole(string.Format(Translations.bot_WebSocketBot_started, _ip, _port.ToString())); @@ -323,18 +342,21 @@ public override void Initialize() return; } - _server.NewSession += (sender, session) => + _server.NewSession += (_, session) => LogToConsole(string.Format(Translations.bot_WebSocketBot_new_session, session.SessionId)); - _server.SessionDropped += (sender, session) => + _server.SessionDropped += (_, session) => LogToConsole(string.Format(Translations.bot_WebSocketBot_session_disconnected, session.SessionId)); - _server.MessageReceived += (sender, messageObject) => + _server.MessageReceived += (_, messageObject) => { if (!ProcessWebsocketCommand(messageObject.SessionId, _password!, messageObject.Message)) return; + var command = messageObject.Message; + command = command.StartsWith('/') ? command[1..] : $"send {command}"; + CmdResult response = new(); - PerformInternalCommand(messageObject.Message, ref response); + PerformInternalCommand(command, ref response); SendSessionEvent(messageObject.SessionId, "OnMccCommandResponse", $"{{\"response\": \"{response}\"}}"); }; }); @@ -391,6 +413,13 @@ private bool ProcessWebsocketCommand(string sessionId, string password, string m return false; } + // If the session is authenticated, remove the old session id and add the new one + if (_authenticatedSessions.Contains(sessionId)) + { + _authenticatedSessions.Remove(sessionId); + _authenticatedSessions.Add(newId); + } + responder.SendSuccessResponse( responder.Quote("The session ID was successfully changed to: '" + newId + "'"), true); LogToConsole(string.Format(Translations.bot_WebSocketBot_session_id_changed, sessionId, newId)); @@ -969,7 +998,7 @@ private bool ProcessWebsocketCommand(string sessionId, string password, string m case "GetProtocolVersion": responder.SendSuccessResponse(JsonConvert.SerializeObject(GetProtocolVersion())); break; - + default: responder.SendErrorResponse( responder.Quote($"Unknown command {cmd.Command} received!")); @@ -1009,7 +1038,6 @@ private bool ProcessWebsocketCommand(string sessionId, string password, string m } } - SendText(message); return true; }