diff --git a/.gitmodules b/.gitmodules index 5ef4ed0f..d927b7f7 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,3 +2,6 @@ path = ConsoleInteractive url = https://github.com/breadbyte/ConsoleInteractive branch = main +[submodule "websocket-sharp"] + path = websocket-sharp + url = https://github.com/sta/websocket-sharp.git diff --git a/MinecraftClient/ChatBots/WebSocketBot.cs b/MinecraftClient/ChatBots/WebSocketBot.cs new file mode 100644 index 00000000..3e96b75a --- /dev/null +++ b/MinecraftClient/ChatBots/WebSocketBot.cs @@ -0,0 +1,1183 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Text.RegularExpressions; +using MinecraftClient.Inventory; +using MinecraftClient.Mapping; +using Newtonsoft.Json; +using Tomlet.Attributes; +using WebSocketSharp; +using WebSocketSharp.Server; + +namespace MinecraftClient.ChatBots +{ + public class WsServer + { + private WebSocketServer _server; + + public static event EventHandler? OnNewSession; + public static event EventHandler? OnSessionClose; + public static event EventHandler? OnMessageRecived; + + public WsServer(string ip, int port) + { + _server = new WebSocketServer(IPAddress.Parse(ip), port); + _server.AddWebSocketService("/mcc"); + _server.Start(); + } + + public void Stop() + { + _server.Stop(); + } + + public class WsBehavior : WebSocketBehavior + { + private string _sessionId { get; set; } = ""; + public bool _authenticated { get; set; } = false; + + public WsBehavior() + { + IgnoreExtensions = true; + } + + protected override void OnOpen() + { + _sessionId = Guid.NewGuid().ToString(); + OnNewSession!.Invoke(this, _sessionId); + } + + protected override void OnClose(CloseEventArgs e) + { + OnSessionClose!.Invoke(this, _sessionId); + } + + protected override void OnMessage(MessageEventArgs websocketEvent) + { + WsServer.OnMessageRecived!.Invoke(this, websocketEvent.Data); + } + + public void SendToClient(string text) + { + Send(text); + } + + public void SetSessionId(string newSessionId) + { + _sessionId = newSessionId; + } + + public string GetSessionId() + { + return _sessionId; + } + + public void SetAuthenticated(bool authenticated) + { + _authenticated = authenticated; + } + + public bool IsAuthenticated() + { + return _authenticated; + } + } + } + + 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 WsServer.WsBehavior _session; + private string _command; + private string _requestId; + + public WsCommandResponder(WebSocketBot bot, WsServer.WsBehavior session, string command, string requestId) + { + _bot = bot; + _session = session; + _command = command; + _requestId = requestId; + } + + private void SendCommandResponse(bool success, string result, bool overrideAuth = false) + { + _bot.SendCommandResponse(_session, 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 Dictionary(keyValuePairs!); + } + } + + public class WebSocketBot : ChatBot + { + private string? _ip; + private int _port; + private string? _password; + private WsServer? _server; + private Dictionary? _sessions; + + public static Configs Config = new(); + + [TomlDoNotInlineObject] + public class Configs + { + public WebSocketBot? botInstance { get; set; } + + [NonSerialized] + private const string BotName = "Websocket"; + + public bool Enabled = false; + + [TomlInlineComment("$config.ChatBot.WebSocketBot.Ip$")] + public string? Ip = "127.0.0.1"; + + [TomlInlineComment("$config.ChatBot.WebSocketBot.Port$")] + public int Port = 8043; + + [TomlInlineComment("$config.ChatBot.WebSocketBot.Password$")] + public string? Password = "wspass12345"; + + public void OnSettingsUpdated() + { + if (botInstance == null) + return; + + Match match = Regex.Match(Ip!, @"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}"); + + if (!match.Success) + { + botInstance!.LogToConsole(Translations.TryGet("bot.WebSocketBot.failed_to_start.ip")); + botInstance!.UnloadBot(); + return; + } + + if (Port > 65535) + { + botInstance!.LogToConsole(Translations.TryGet("bot.WebSocketBot.failed_to_start.port")); + botInstance!.UnloadBot(); + return; + } + + botInstance!._ip = Ip; + botInstance!._port = Port; + botInstance!._password = Password; + + botInstance!.Initialize(); + } + } + + public WebSocketBot() + { + Config.botInstance = this; + + Match match = Regex.Match(Config.Ip!, @"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}"); + + if (!match.Success) + { + LogToConsole(Translations.TryGet("bot.WebSocketBot.failed_to_start.ip")); + return; + } + + if (Config.Port > 65535) + { + LogToConsole(Translations.TryGet("bot.WebSocketBot.failed_to_start.port")); + return; + } + + _ip = Config.Ip; + _port = Config.Port; + _password = Config.Password; + } + + public override void Initialize() + { + if (_server != null) + { + SendEvent("OnWsRestarting", ""); + _server.Stop(); + } + + try + { + LogToConsole(Translations.TryGet("bot.WebSocketBot.starting")); + _server = new WsServer(_ip!, _port); + _sessions = new Dictionary(); + + LogToConsole(Translations.TryGet("bot.WebSocketBot.started", _ip, _port)); + } + catch (Exception e) + { + LogToConsole(Translations.TryGet("bot.WebSocketBot.failed_to_start.custom", e)); + return; + } + + WsServer.OnNewSession += (sender, id) => + { + LogToConsole(Translations.TryGet("bot.WebSocketBot.new_session", id)); + _sessions[id] = (WsServer.WsBehavior)sender!; + }; + + WsServer.OnSessionClose += (sender, id) => + { + LogToConsole(Translations.TryGet("bot.WebSocketBot.session_disconnected", id)); + _sessions.Remove(id); + }; + + WsServer.OnMessageRecived += (sender, message) => + { + var session = (WsServer.WsBehavior)sender!; + + if (!ProcessWebsocketCommand(session, _password!, message)) + return; + + string? result = ""; + PerformInternalCommand(message, ref result); + SendSessionEvent(session, "OnMccCommandResponse", "{\"response\": \"" + result + "\"}"); + }; + } + + public bool ProcessWebsocketCommand(WsServer.WsBehavior session, string password, string message) + { + message = message.Trim(); + + if (string.IsNullOrEmpty(message)) + return false; + + if (message.StartsWith('{')) + { + try + { + LogDebugToConsole("\n\n\tGot command\n\n\t" + message + "\n\n"); + + WsChatBotCommand cmd = JsonConvert.DeserializeObject(message)!; + WsCommandResponder responder = new WsCommandResponder(this, session, cmd.Command, cmd.RequestId); + + // Authentication and session commands + if (password.Length != 0) + { + if (!session.IsAuthenticated()) + { + // Allow session name changing before login for easier identification + if (cmd.Command.Equals("ChangeSessionId", StringComparison.OrdinalIgnoreCase)) + { + if (cmd.Parameters == null || cmd.Parameters.Length != 1) + { + responder.SendErrorResponse(responder.Quote("Invalid number of parameters, expected 1 (newSessionid)!"), true); + return false; + } + + string newId = (cmd.Parameters[0] as string)!; + + if (newId.Length == 0) + { + responder.SendErrorResponse(responder.Quote("Please provide a valid session ID!"), true); + return false; + } + + if (newId.Length > 32) + { + responder.SendErrorResponse(responder.Quote("The session ID can't be longer than 32 characters!"), true); + return false; + } + + string oldId = session.GetSessionId(); + + _sessions!.Remove(oldId); + _sessions[newId] = session; + session.SetSessionId(newId); + + responder.SendSuccessResponse(responder.Quote("The session ID was successfully changed to: '" + newId + "'"), true); + LogToConsole(Translations.TryGet("bot.WebSocketBot.session_id_changed", oldId, newId)); + return false; + } + + // Special case for authentication + if (cmd.Command.Equals("Authenticate", StringComparison.OrdinalIgnoreCase)) + { + if (cmd.Parameters == null || cmd.Parameters.Length != 1) + { + responder.SendErrorResponse(responder.Quote("Invalid number of parameters, expected 1 (password)!"), true); + return false; + } + + string 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; + } + + session.SetAuthenticated(true); + responder.SendSuccessResponse(responder.Quote("Succesfully authenticated!"), true); + LogToConsole(Translations.TryGet("bot.WebSocketBot.session_authenticated", session.GetSessionId())); + return false; + } + + responder.SendErrorResponse(responder.Quote("You must authenticate in order to send and recieve data!"), true); + return false; + } + } + else + { + if (!session.IsAuthenticated()) + { + responder.SendSuccessResponse(responder.Quote("Succesfully authenticated!")); + LogToConsole(Translations.TryGet("bot.WebSocketBot.session_authenticated", session.GetSessionId())); + session.SetAuthenticated(true); + 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 == null || cmd.Parameters.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 == null || cmd.Parameters.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 == null || cmd.Parameters.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 == null || cmd.Parameters.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 == null || cmd.Parameters.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 == null || cmd.Parameters.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; + } + + Location 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; + } + + bool resullt = false; + + if (cmd.Parameters.Length == 3) + resullt = DigBlock(location); + else if (cmd.Parameters.Length == 4) + resullt = DigBlock(location, (bool)cmd.Parameters[3]); + else if (cmd.Parameters.Length == 5) + resullt = DigBlock(location, (bool)cmd.Parameters[3], (bool)cmd.Parameters[4]); + + responder.SendSuccessResponse(JsonConvert.SerializeObject(resullt)); + break; + + case "SetSlot": + if (cmd.Parameters == null || cmd.Parameters.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; + } + + bool allowUnsafe = false; + bool allowDirectTeleport = false; + int maxOffset = 0; + int 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])); + + bool 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; + } + + InteractType interactionType = (InteractType)Convert.ToInt32(cmd.Parameters[1]); + + Hand 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 == null || cmd.Parameters.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": + Hand hand = Hand.MainHand; + + if (cmd.Parameters != null && cmd.Parameters.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; + } + + Location blockLocation = new Location(Convert.ToInt32(cmd.Parameters[0]), Convert.ToInt32(cmd.Parameters[1]), Convert.ToInt32(cmd.Parameters[2])); + + Direction blockFacingDirection = (Direction)Convert.ToInt32(cmd.Parameters[3]); + Hand 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 == null || cmd.Parameters.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 == null || cmd.Parameters.Length != 7) + { + responder.SendErrorResponse(responder.Quote("Invalid number of parameters, expecting 1 parameter (x, y, z, line1, line2, line3, line4)!")); + return false; + } + + Location 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 == null || cmd.Parameters.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 == null || cmd.Parameters.Length != 6) + { + responder.SendErrorResponse(responder.Quote("Invalid number of parameters, expecting 1 parameter (x, y, z, command, commandBlockMode, commandBlockFlags)!")); + return false; + } + + Location 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 == null || cmd.Parameters.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; + } + } + catch (Exception e) + { + LogDebugToConsole(e.Message); + SendSessionEvent(session, "OnWsCommandResponse", "{\"success\": false, \"message\": \"An error occured, possible reasons: mailformed json, type conversion, internal error\", \"stackTrace\": \"" + Json.EscapeString(e.ToString()) + "\"}", true); + return false; + } + + return false; + } + else + { + if (password.Length != 0) + { + if (!session.IsAuthenticated()) + { + SendSessionEvent(session, "OnWsCommandResponse", "{\"error\": true, \"message\": \"You must authenticate in order to send and recieve data!\"}", true); + return false; + } + } + else + { + if (!session.IsAuthenticated()) + { + SendSessionEvent(session, "OnWsCommandResponse", "{\"success\": true, \"message\": \"Succesfully authenticated!\"}", true); + LogToConsole(Translations.TryGet("bot.WebSocketBot.session_authenticated", session.GetSessionId())); + session.SetAuthenticated(true); + return true; + } + } + } + + return true; + } + + public override void OnUnload() + { + if (_server != null) + { + SendEvent("OnWsConnectionClose", ""); + _server.Stop(); + } + } + + // ========================================================================================== + // Bot Events + // ========================================================================================== + public override void AfterGameJoined() + { + SendEvent("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(); + + string message = ""; + string 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) + { + string reasonString = "Unknown"; + + switch (reason) + { + case DisconnectReason.ConnectionLost: + reasonString = "Connection Lost"; + break; + + case DisconnectReason.UserLogout: + reasonString = "User Logout"; + break; + + case DisconnectReason.InGameKick: + reasonString = "In-Game Kick"; + break; + + case DisconnectReason.LoginRejected: + reasonString = "Login Rejected"; + break; + } + + 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 = WorldAge, timeOfDay = TimeOfDay }); + } + + public override void OnEntityMove(Entity entity) + { + SendEvent("OnEntityMove", entity); + } + + public override void OnInternalCommand(string commandName, string commandParams, string Result) + { + SendEvent("OnInternalCommand", new { command = commandName, parameters = commandParams, result = Result.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 = recordcount }); + } + + public override void OnSetExperience(float Experiencebar, int Level, int TotalExperience) + { + SendEvent("OnSetExperience", new { experienceBar = Experiencebar, level = Level, totalExperience = TotalExperience }); + } + + public override void OnGamemodeUpdate(string playername, Guid uuid, int gamemode) + { + SendEvent("OnGamemodeUpdate", new { playerName = playername, uuid, gameMode = GameModeString(gamemode) }); + } + + public override void OnLatencyUpdate(string playername, Guid uuid, int latency) + { + SendEvent("OnLatencyUpdate", new { playerName = playername, uuid, latency }); + } + + public override void OnMapData(int mapid, byte scale, bool trackingPosition, bool locked, List icons, byte columnsUpdated, byte rowsUpdated, byte mapCoulmnX, byte mapRowZ, byte[]? colors) + { + SendEvent("OnMapData", new { mapId = mapid, scale, trackingPosition, locked, icons, columnsUpdated, rowsUpdated, mapCoulmnX, mapRowZ, colors }); + } + + public override void OnTradeList(int windowID, List trades, VillagerInfo villagerInfo) + { + SendEvent("OnTradeList", new { windowId = 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 = titletext, subtitleText = subtitletext, actionBarText = 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 = objectivename, mode, objectiveValue = objectivevalue, type, rawJson = json_ }); + } + + public override void OnUpdateScore(string entityname, int action, string objectivename, int value) + { + SendEvent("OnUpdateScore", new { entityName = entityname, action, objectiveName = 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 ? "null" : name) }); + } + + 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) + { + foreach (KeyValuePair pair in _sessions!) + SendSessionEvent(pair.Value, type, JsonConvert.SerializeObject(data), overrideAuth); + } + + private void SendEvent(string type, string data, bool overrideAuth = false) + { + foreach (KeyValuePair pair in _sessions!) + SendSessionEvent(pair.Value, type, data, overrideAuth); + } + + private void SendSessionEvent(WsServer.WsBehavior session, string type, string data, bool overrideAuth = false) + { + if (session != null && (overrideAuth || session.IsAuthenticated())) + { + session.SendToClient("{\"event\": \"" + type + "\", \"data\": " + (data.IsNullOrEmpty() ? "null" : "\"" + Json.EscapeString(data) + "\"") + "}"); + + if (!(type.Contains("Entity") || type.Equals("OnTimeUpdate") || type.Equals("OnServerTpsUpdate"))) + LogDebugToConsole( + "\n\n\tSending:\n\n\t{\"event\": \"" + type + + "\", \"data\": " + (data.IsNullOrEmpty() ? "null" : "\"" + + Json.EscapeString(data) + "\"") + "}\n\n"); + } + } + + public void SendCommandResponse(WsServer.WsBehavior session, bool success, string requestId, string command, string result, bool overrideAuth = false) + { + SendSessionEvent(session, "OnWsCommandResponse", "{\"success\": " + success.ToString().ToLower() + ", \"requestId\": \"" + requestId + "\", \"command\": \"" + command + "\", \"result\": " + (string.IsNullOrEmpty(result) ? "null" : result) + "}", overrideAuth); + } + + public string GameModeString(int gameMode) + { + if (gameMode == 0) + return "survival"; + + if (gameMode == 1) + return "creative"; + + if (gameMode == 2) + return "adventure"; + + if (gameMode == 3) + return "spectator"; + + return "unknown"; + } + } +} diff --git a/MinecraftClient/Json.cs b/MinecraftClient/Json.cs index e77e32bf..b4b0008f 100644 --- a/MinecraftClient/Json.cs +++ b/MinecraftClient/Json.cs @@ -103,7 +103,7 @@ namespace MinecraftClient && 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; } @@ -221,5 +221,60 @@ namespace MinecraftClient || 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) + { + char c = src[i]; + return c < 32 || c == '"' || c == '\\' + // Broken lead surrogate + || (c >= '\uD800' && c <= '\uDBFF' && + (i == src.Length - 1 || src[i + 1] < '\uDC00' || src[i + 1] > '\uDFFF')) + // Broken tail surrogate + || (c >= '\uDC00' && c <= '\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) + { + StringBuilder sb = new StringBuilder(); + + int start = 0; + + for (int i = 0; i < src.Length; i++) + { + if (NeedEscape(src, i)) + { + 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(); + } } } diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs index 677cad72..fafc781f 100644 --- a/MinecraftClient/McClient.cs +++ b/MinecraftClient/McClient.cs @@ -273,6 +273,7 @@ namespace MinecraftClient if (Config.ChatBot.RemoteControl.Enabled) { BotLoad(new RemoteControl()); } if (Config.ChatBot.ReplayCapture.Enabled && reload) { BotLoad(new ReplayCapture()); } if (Config.ChatBot.ScriptScheduler.Enabled) { BotLoad(new ScriptScheduler()); } + if (Config.ChatBot.WebSocketBot.Enabled) { BotLoad(new WebSocketBot()); } //Add your ChatBot here by uncommenting and adapting //BotLoad(new ChatBots.YourBot()); } diff --git a/MinecraftClient/MinecraftClient.csproj b/MinecraftClient/MinecraftClient.csproj index b7f45d16..fb2e19b8 100644 --- a/MinecraftClient/MinecraftClient.csproj +++ b/MinecraftClient/MinecraftClient.csproj @@ -39,6 +39,7 @@ + @@ -72,6 +73,7 @@ + diff --git a/MinecraftClient/Resources/lang/en.ini b/MinecraftClient/Resources/lang/en.ini index 3543b494..0c7fdbe0 100644 --- a/MinecraftClient/Resources/lang/en.ini +++ b/MinecraftClient/Resources/lang/en.ini @@ -700,6 +700,17 @@ bot.scriptScheduler.running_inverval=Interval / Running action: {0} bot.scriptScheduler.running_login=Login / Running action: {0} bot.scriptScheduler.task=triggeronfirstlogin: {0}\n triggeronlogin: {1}\n triggerontime: {2}\n triggeroninterval: {3}\n timevalue: {4}\n timeinterval: {5}\n action: {6} +# WebSocketBot +bot.WebSocketBot.session_id_changed=§bSession with an id §a{0}§b has been renamed to: §a{1}§b! +bot.WebSocketBot.session_authenticated=§bSession with an id §a{0}§b has been succesfully authenticated! +bot.WebSocketBot.failed_to_start.ip=§cFailed to start a server! The provided IP address is not a valid one! +bot.WebSocketBot.failed_to_start.port=§cFailed to start a server! The port number provided is out of the range, it must be 65535 or bellow it! +bot.WebSocketBot.failed_to_start.custom=§cFailed to start a server:\n\n{0}\n\n +bot.WebSocketBot.starting=Starting the Websocket server... +bot.WebSocketBot.started=§bServer started on ip §a{0}§b port: §a{1} +bot.WebSocketBot.new_session=§bNew session connected: §a{0} +bot.WebSocketBot.session_disconnected=§bSession with an id §a{0}§b has disconnected! + # TestBot bot.testBot.told=Bot: {0} told me : {1} bot.testBot.said=Bot: {0} said : {1} @@ -940,3 +951,9 @@ config.ChatBot.ReplayCapture.Backup_Interval=How long should replay file be auto # ChatBot.ScriptScheduler config.ChatBot.ScriptScheduler=Schedule commands and scripts to launch on various events such as server join, date/time or time interval\n# See https://mccteam.github.io/guide/chat-bots.html#script-scheduler for more info + +# ChatBot.WebSocketBot +config.ChatBot.WebSocketBot=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. +config.ChatBot.WebSocketBot.Ip=The IP address that Websocket server will be bounded to. +config.ChatBot.WebSocketBot.Port=The Port that Websocket server will be bounded to. +config.ChatBot.WebSocketBot.Password=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). diff --git a/MinecraftClient/Scripting/ChatBot.cs b/MinecraftClient/Scripting/ChatBot.cs index 14fefcde..8ce7de16 100644 --- a/MinecraftClient/Scripting/ChatBot.cs +++ b/MinecraftClient/Scripting/ChatBot.cs @@ -1009,7 +1009,7 @@ namespace MinecraftClient /// /// 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 7880ead0..9671c235 100644 --- a/MinecraftClient/Settings.cs +++ b/MinecraftClient/Settings.cs @@ -567,7 +567,7 @@ namespace MinecraftClient public enum ForgeConfigType { no, auto, force }; - public enum TerminalColorDepthType { bit_4, bit_8, bit_24}; + public enum TerminalColorDepthType { bit_4, bit_8, bit_24 }; } public struct AccountInfoConfig @@ -1152,6 +1152,13 @@ namespace MinecraftClient get { return ChatBots.ScriptScheduler.Config; } set { ChatBots.ScriptScheduler.Config = value; ChatBots.ScriptScheduler.Config.OnSettingUpdate(); } } + + [TomlPrecedingComment("$config.ChatBot.WebSocketBot$")] + public ChatBots.WebSocketBot.Configs WebSocketBot + { + get { return ChatBots.WebSocketBot.Config!; } + set { ChatBots.WebSocketBot.Config = value; } + } } } diff --git a/websocket-sharp b/websocket-sharp new file mode 160000 index 00000000..8e175845 --- /dev/null +++ b/websocket-sharp @@ -0,0 +1 @@ +Subproject commit 8e175845420d7dd707d7636747a71a0b5037ba91