using System.Net; using System.Net.WebSockets; using System.Text; using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Unicode; using KfChatDotNetWsClient.Models; using KfChatDotNetWsClient.Models.Events; using KfChatDotNetWsClient.Models.Json; using NLog; using Websocket.Client; // It's a fucking lie. You must use conditional access or you WILL get NullReferenceErrors if an event is not in use // ReSharper disable ConditionalAccessQualifierIsNonNullableAccordingToAPIContract namespace KfChatDotNetWsClient; public class ChatClient { public event EventHandlers.OnMessagesEventHandler? OnMessages; public event EventHandlers.OnUsersPartedEventHandler? OnUsersParted; public event EventHandlers.OnUsersJoinedEventHandler? OnUsersJoined; public event EventHandlers.OnWsReconnectEventHandler? OnWsReconnect; public event EventHandlers.OnDeleteMessagesEventHandler? OnDeleteMessages; public event EventHandlers.OnWsDisconnectionEventHandler? OnWsDisconnection; public event EventHandlers.OnFailedToJoinRoom? OnFailedToJoinRoom; public event EventHandlers.OnUnknownCommand? OnUnknownCommand; public event EventHandlers.OnPermissionsEventHandler? OnPermissions; public event EventHandlers.OnSystemMessage? OnSystemMessage; public event EventHandlers.OnMotdEventHandler? OnMotd; public event EventHandlers.OnWhisperEventHandler? OnWhisper; private WebsocketClient? _wsClient; private readonly Logger _logger = LogManager.GetCurrentClassLogger(); private ChatClientConfigModel _config; public DateTime LastPacketReceived = DateTime.UtcNow; public ChatClient(ChatClientConfigModel config) { _config = config; } public void UpdateConfig(ChatClientConfigModel config) { _config = config; } public void UpdateCookies(Dictionary cookies) { _config.Cookies = cookies; } public async Task StartWsClient() { await CreateWsClient(); } public void Disconnect() { if (_wsClient == null) throw new WebSocketNotInitializedException(); _wsClient.Stop(WebSocketCloseStatus.NormalClosure, "Closing websocket").Wait(); } // Bot is inconsistent with what methods are async and not but I don't want to change the Disconnect() method to be // async as then it'll fuck up anyone not waiting for it. This is why there's an explicit async method for this but // none for reconnect. public async Task DisconnectAsync() { if (_wsClient == null) throw new WebSocketNotInitializedException(); await _wsClient.Stop(WebSocketCloseStatus.NormalClosure, "Closing websocket"); } public async Task ReconnectAsync() { if (_wsClient == null) throw new WebSocketNotInitializedException(); await _wsClient.Reconnect(); } private async Task CreateWsClient() { var factory = new Func(() => { var clientWs = new ClientWebSocket(); if (_config.Proxy != null) { clientWs.Options.Proxy = new WebProxy(_config.Proxy); } // Guest mode if (_config.Cookies.Keys.Count == 0) { return clientWs; } var cookieContainer = new CookieContainer(); foreach (var key in _config.Cookies.Keys) { cookieContainer.Add(new Cookie(key, _config.Cookies[key], "/", _config.CookieDomain)); } clientWs.Options.Cookies = cookieContainer; return clientWs; }); var client = new WebsocketClient(_config.WsUri, factory) { ReconnectTimeout = TimeSpan.FromSeconds(_config.ReconnectTimeout) }; _wsClient = client; client.ReconnectionHappened.Subscribe(WsReconnection); client.MessageReceived.Subscribe(WsMessageReceived); client.DisconnectionHappened.Subscribe(WsDisconnection); _logger.Debug("Websocket client has been built, about to start"); await client.Start(); _logger.Debug("Websocket client started!"); } public bool IsConnected() { return _wsClient is { IsRunning: true }; } private void WsDisconnection(DisconnectionInfo disconnectionInfo) { _logger.Error($"Client disconnected from the chat (or never successfully connected). Type is {disconnectionInfo.Type}"); _logger.Error($"Close Status => {disconnectionInfo.CloseStatus}; Close Status Description => {disconnectionInfo.CloseStatusDescription}"); _logger.Error(disconnectionInfo.Exception); OnWsDisconnection?.Invoke(this, disconnectionInfo); } private void WsReconnection(ReconnectionInfo reconnectionInfo) { _logger.Error($"Websocket connection dropped and reconnected. Reconnection type is {reconnectionInfo.Type}"); OnWsReconnect?.Invoke(this, reconnectionInfo); } private void WsMessageReceived(ResponseMessage message) { LastPacketReceived = DateTime.UtcNow; if (message.Text == null) { _logger.Info("Websocket message was null, ignoring packet"); return; } if (message.Text.StartsWith("You cannot join this room")) { _logger.Debug("Got a message saying we failed to join the room"); OnFailedToJoinRoom?.Invoke(this, message.Text); return; } if (message.Text.StartsWith("Unknown command")) { _logger.Debug("Unknown command received"); OnUnknownCommand?.Invoke(this, message.Text); return; } Dictionary packetType; try { packetType = JsonSerializer.Deserialize>(message.Text)!; } catch (Exception e) { _logger.Error("Failed to parse packet"); _logger.Error(e); _logger.Error($"Packet contents: {message.Text}"); return; } _logger.Debug($"Received packet from KF: {string.Join(',', packetType.Keys)}"); // Message(s) received if (packetType.ContainsKey("messages")) { _logger.Debug("Looks like it's a chat message"); WsChatMessagesReceived(message); return; } // User(s) joined if (packetType.ContainsKey("users")) { _logger.Debug("Looks like this is a user(s) joined packet"); WsChatUsersJoined(message); return; } // User(s) parted if (packetType.ContainsKey("user")) { _logger.Debug("Looks like this is a user(s) parted packet"); WsChatUsersParted(message); return; } if (packetType.ContainsKey("permissions")) { _logger.Debug("Looks like we got permissions"); WsPermissions(message); return; } if (packetType.ContainsKey("system")) { _logger.Debug("Looks like a system message"); WsSystemMessage(message); return; } if (packetType.ContainsKey("delete")) { _logger.Debug($"Looks like this is a message deletion packet"); WsDeleteMessagesReceived(message); return; } if (packetType.ContainsKey("whisper")) { _logger.Debug("Looks like this is a whisper packet"); WsWhisper(message); return; } if (packetType.ContainsKey("motd")) { _logger.Debug("Looks like this is an MOTD packet"); WsMotd(message); return; } _logger.Info($"Received packet this was not handled: {message.Text}"); } public void JoinRoom(int roomId) { _logger.Debug($"Joining {roomId}"); if (_wsClient == null) throw new WebSocketNotInitializedException(); _wsClient.Send($"/join {roomId}"); } public void SendMessage(string message) { _logger.Debug($"Sending '{message}'"); if (_wsClient == null) throw new WebSocketNotInitializedException(); _wsClient.Send(message); } public async Task SendMessageInstantAsync(string message) { _logger.Debug($"Sending '{message}', bypassing the queue"); if (_wsClient == null) throw new WebSocketNotInitializedException(); await _wsClient.SendInstant(message); } public void DeleteMessage(int messageId) { _logger.Debug($"Deleting {messageId}"); if (_wsClient == null) throw new WebSocketNotInitializedException(); _wsClient.Send($"/delete {messageId}"); } public async Task DeleteMessageAsync(string messageUuid) { _logger.Debug($"Deleting {messageUuid}"); if (_wsClient == null) throw new WebSocketNotInitializedException(); await _wsClient.SendInstant($"/delete {messageUuid}"); } public void EditMessage(string messageUuid, string newMessage) { var payload = JsonSerializer.Serialize(new EditMessageJsonModel {Uuid = messageUuid, Message = newMessage}); _logger.Debug($"Editing {messageUuid} with '{newMessage}'"); if (_wsClient == null) throw new WebSocketNotInitializedException(); _wsClient.Send($"/edit {payload}"); } public async Task EditMessageAsync(string messageUuid, string newMessage) { var settings = new TextEncoderSettings(); settings.AllowRange(UnicodeRanges.All); var options = new JsonSerializerOptions() { Encoder = JavaScriptEncoder.Create(settings) }; var payload = JsonSerializer.Serialize(new EditMessageJsonModel {Uuid = messageUuid, Message = newMessage}, options); _logger.Debug($"Editing {messageUuid} with '{newMessage}'"); if (_wsClient == null) throw new WebSocketNotInitializedException(); var msg = $"/edit {payload}"; var length = Encoding.UTF8.GetByteCount(msg); if (length > 2048) { _logger.Error($"Edit message is too long at {length} bytes"); } await _wsClient.SendInstant($"/edit {payload}"); } public async Task SetMotd(string messageUuid) { _logger.Debug($"Setting {messageUuid} as the MOTD"); if (_wsClient == null) throw new WebSocketNotInitializedException(); await _wsClient.SendInstant($"/motd {messageUuid}"); } private void WsDeleteMessagesReceived(ResponseMessage message) { var data = JsonSerializer.Deserialize(message.Text!); _logger.Debug($"Received delete packet for messages: {string.Join(',', data!.MessageIdsToDelete)}"); OnDeleteMessages?.Invoke(this, data.MessageIdsToDelete); } private void WsChatMessagesReceived(ResponseMessage message) { var data = JsonSerializer.Deserialize(message.Text!); var messages = new List(); foreach (var chatMessage in data!.Messages) { var model = new MessageModel { Author = new UserModel { Id = chatMessage.Author.Id, Username = chatMessage.Author.Username, AvatarUrl = chatMessage.Author.AvatarUrl, // It isn't sent on chat messages LastActivity = null }, Message = chatMessage.Message, MessageUuid = chatMessage.MessageUuid, MessageRaw = chatMessage.MessageRaw, RoomId = chatMessage.RoomId, MessageRawHtmlDecoded = WebUtility.HtmlDecode(chatMessage.MessageRaw), MessageDate = DateTimeOffset.FromUnixTimeSeconds(chatMessage.MessageDate) }; if (chatMessage.MessageEditDate == 0) { model.MessageEditDate = null; } else { model.MessageEditDate = DateTimeOffset.FromUnixTimeSeconds(chatMessage.MessageEditDate); } messages.Add(model); } _logger.Debug($"Received {messages.Count} chat messages"); if (messages.Count == 1) { _logger.Debug($"{JsonSerializer.Serialize(messages[0])}"); } try { OnMessages?.Invoke(this, messages, data); } catch (Exception e) { _logger.Error("Our handler for chat messages threw an exception"); _logger.Error(e); } } private void WsChatUsersJoined(ResponseMessage message) { var data = JsonSerializer.Deserialize(message.Text!); var users = new List(); foreach (var user in data!.Users.Keys) { users.Add(new UserModel { Id = int.Parse(user), Username = data.Users[user].Username, AvatarUrl = data.Users[user].AvatarUrl, LastActivity = DateTimeOffset.FromUnixTimeSeconds(data.Users[user].LastActivity) }); } var usersJoined= data.Users.Select(user => int.Parse(user.Key)).ToList(); _logger.Debug($"Following users have joined: {string.Join(',', usersJoined)}"); OnUsersJoined?.Invoke(this, users, data); } private void WsChatUsersParted(ResponseMessage message) { // {"user":{"1337":false}} var data = JsonSerializer.Deserialize>>(message.Text!); var usersParted = data!["user"].Select(user => int.Parse(user.Key)).ToList(); _logger.Debug($"Following users have parted: {string.Join(',', usersParted)}"); OnUsersParted?.Invoke(this, usersParted); } private void WsPermissions(ResponseMessage message) { var data = JsonSerializer.Deserialize(message.Text!); var permissions = data.GetProperty("permissions").Deserialize(); try { OnPermissions?.Invoke(this, permissions!); } catch (Exception e) { _logger.Error("Caught error when invoking OnPermissions"); _logger.Error(e); } } private void WsSystemMessage(ResponseMessage message) { var msg = JsonSerializer.Deserialize(message.Text!).GetProperty("system").GetString(); try { OnSystemMessage?.Invoke(this, msg!); } catch (Exception e) { _logger.Error("Caught error when invoking OnSystemMessage"); _logger.Error(e); } } private void WsWhisper(ResponseMessage message) { var data = JsonSerializer.Deserialize(message.Text!).GetProperty("whisper") .Deserialize(); var model = new WhisperModel { Author = new UserModel { Id = data!.Author.Id, Username = data.Author.Username, AvatarUrl = data.Author.AvatarUrl }, Recipient = new UserModel { Id = data.Recipient.Id, Username = data.Recipient.Username, AvatarUrl = data.Recipient.AvatarUrl }, Message = data.Message, MessageRaw = data.MessageRaw, MessageRawHtmlDecoded = WebUtility.HtmlDecode(data.MessageRaw), MessageDate = DateTimeOffset.FromUnixTimeSeconds(data.MessageDate) }; try { OnWhisper?.Invoke(this, model); } catch (Exception e) { _logger.Error("WS handler for whisper threw an exception when invoking OnWhisper"); _logger.Error(e); } } private void WsMotd(ResponseMessage message) { var motdElement = JsonSerializer.Deserialize(message.Text!).GetProperty("motd"); //.Deserialize(); if (motdElement.ValueKind == JsonValueKind.Null) return; var msg = motdElement.Deserialize(); var model = new MessageModel { Author = new UserModel { Id = msg!.Author.Id, Username = msg.Author.Username, AvatarUrl = msg.Author.AvatarUrl, // It isn't sent on chat messages LastActivity = null }, Message = msg.Message, MessageUuid = msg.MessageUuid, MessageRaw = msg.MessageRaw, RoomId = msg.RoomId, MessageRawHtmlDecoded = WebUtility.HtmlDecode(msg.MessageRaw), MessageDate = DateTimeOffset.FromUnixTimeSeconds(msg.MessageDate) }; if (msg.MessageEditDate == 0) { model.MessageEditDate = null; } else { model.MessageEditDate = DateTimeOffset.FromUnixTimeSeconds(msg.MessageEditDate); } try { OnMotd?.Invoke(this, model); } catch (Exception e) { _logger.Error("The handler for MOTD messages threw an exception"); _logger.Error(e); } } } public class WebSocketNotInitializedException : Exception;